diff --git a/src/components/ExamEditor/ExercisePicker/exercises.ts b/src/components/ExamEditor/ExercisePicker/exercises.ts index 158f695a..5c34935d 100644 --- a/src/components/ExamEditor/ExercisePicker/exercises.ts +++ b/src/components/ExamEditor/ExercisePicker/exercises.ts @@ -166,7 +166,22 @@ const listening = (section: number) => { generate() ], module: "listening" - } + }, + { + label: `Section ${section} - True False`, + type: `listening_${section}`, + icon: FaCheckSquare, + sectionId: section, + extra: [ + { + param: "name", + value: "trueFalse" + }, + quantity(4, "Quantity of Statements"), + generate() + ], + module: "listening" + }, ]; if (section === 1 || section === 4) { @@ -285,7 +300,8 @@ const EXERCISES: ExerciseGen[] = [ ], module: "level" }, - { + // Removing this since level supports reading aswell + /*{ label: "Reading Passage: Multiple Choice", type: "passageUtas", icon: FaBookOpen, @@ -295,11 +311,11 @@ const EXERCISES: ExerciseGen[] = [ value: "passageUtas" }, // in the utas exam there was only mc so I'm assuming short answers are deprecated - /*{ - label: "Short Answers", - param: "sa_qty", - value: "10" - },*/ + //{ + // label: "Short Answers", + // param: "sa_qty", + // value: "10" + //}, quantity(10, "Multiple Choice Quantity"), { label: "Reading Passage Topic", @@ -315,7 +331,7 @@ const EXERCISES: ExerciseGen[] = [ generate() ], module: "level" - }, + },*/ { label: "Task 1 - Letter", type: "writing_letter", diff --git a/src/components/ExamEditor/ExercisePicker/index.tsx b/src/components/ExamEditor/ExercisePicker/index.tsx index 2ff6bce8..81f69128 100644 --- a/src/components/ExamEditor/ExercisePicker/index.tsx +++ b/src/components/ExamEditor/ExercisePicker/index.tsx @@ -130,25 +130,51 @@ const ExercisePicker: React.FC = ({ ); }); } else { - /*const newExercises = configurations.map((config) => { - switch (config.type) { - case 'writing_letter': - return { ...writingTask(1), level: true }; - case 'writing_2': - return { ...writingTask(2), level: true }; - } - return undefined; - }).filter((ex) => ex !== undefined); - dispatch({ - type: "UPDATE_SECTION_STATE", payload: { - module: level ? "level" : module as Module, sectionId, update: { - exercises: [ - ...(sections.find((s) => s.sectionId = sectionId)?.state as LevelPart).exercises, - ...newExercises - ] - } - } - })*/ + configurations.forEach((config) => { + let queryParams = Object.fromEntries( + Object.entries({ + topic: config.params.topic as string, + first_topic: config.params.first_topic as string, + second_topic: config.params.second_topic as string, + }).filter(([_, value]) => value && value !== '') + ); + let query = Object.keys(queryParams).length === 0 ? undefined : queryParams; + generate( + Number(config.type.split('_')[1]), + "speaking", + config.type, + { + method: 'GET', + queryParams: query + }, + (data: any) => { + switch (Number(config.type.split('_')[1])) { + case 1: + return [{ + prompts: data.questions, + first_topic: data.first_topic, + second_topic: data.second_topic + }]; + case 2: + return [{ + topic: data.topic, + question: data.question, + prompts: data.prompts, + suffix: data.suffix + }]; + case 3: + return [{ + topic: data.topic, + questions: data.questions + }]; + default: + return [data]; + } + }, + levelSectionId, + level + ); + }); } setLocalSelectedExercises([]); setPickerOpen(false); diff --git a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx index d9c2f8da..f9107cdc 100644 --- a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx @@ -8,7 +8,7 @@ import GenLoader from "../Shared/GenLoader"; import { useEffect, useState } from "react"; import useSectionEdit from "../../Hooks/useSectionEdit"; import useExamEditorStore from "@/stores/examEditor"; -import { InteractiveSpeakingExercise } from "@/interfaces/exam"; +import { InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam"; import { BsFileText } from "react-icons/bs"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; import { RiVideoLine } from "react-icons/ri"; @@ -23,10 +23,9 @@ interface Props { const InteractiveSpeaking: React.FC = ({ sectionId, exercise, module = "speaking" }) => { const { dispatch } = useExamEditorStore(); const [local, setLocal] = useState(exercise); - const [currentVideoIndex, setCurrentVideoIndex] = useState(0); - const { generating, genResult, state } = useExamEditorStore( + const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore( (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! ); @@ -34,48 +33,94 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise, module = "s sectionId, onSave: () => { setEditing(false); - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local, module: module } }); + + if (module === "level") { + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? local : ex + ) + }; + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + } else { + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: local, module } + }); + } + if (genResult) { dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, - module: module, + module, field: "genResult", value: undefined } }); } + + const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`); + if (module === "level" && speakingScript) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenResults", + value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`), + module + } + }); + } }, onDiscard: () => { setLocal(exercise); }, onPractice: () => { - const updatedExercise = { - ...state, - isPractice: !local.isPractice - }; - setLocal((prev) => ({...prev, isPractice: !local.isPractice})) - dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } }); + const updatedLocal = { ...local, isPractice: !local.isPractice }; + setLocal(updatedLocal); + + if (module === "level") { + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? updatedLocal : ex + ) + }; + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + } else { + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedLocal, module } + }); + } }, }); useEffect(() => { if (genResult && generating === "speakingScript") { - setEditing(true); - setLocal({ + const updatedLocal = { ...local, title: genResult.result[0].title, prompts: genResult.result[0].prompts.map((item: any) => ({ text: item || "", video_url: "" })) - }); + }; + setEditing(true); + setLocal(updatedLocal); + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, - module: module, + module, field: "generating", value: undefined } @@ -84,6 +129,90 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise, module = "s // eslint-disable-next-line react-hooks/exhaustive-deps }, [genResult, generating]); + useEffect(() => { + if (genResult && generating === "video") { + const updatedLocal = { ...local, prompts: genResult.result[0].prompts }; + setLocal(updatedLocal); + + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedLocal, module } + }); + + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module, + field: "generating", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + useEffect(() => { + const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`); + const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`); + + if (speakingScript && isGenerating) { + const updatedLocal = { + ...local, + title: speakingScript.result[0].title, + prompts: speakingScript.result[0].prompts.map((item: any) => ({ + text: item || "", + video_url: "" + })) + }; + setEditing(true); + setLocal(updatedLocal); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenerating", + value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`), + module + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelGenResults, levelGenerating]); + + useEffect(() => { + const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`); + const isGenerating = levelGenerating?.includes(`${local.id}-video`); + + if (speakingVideo && isGenerating) { + const updatedLocal = { ...local, prompts: speakingVideo.result[0].prompts }; + setLocal(updatedLocal); + + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? updatedLocal : ex + ) + }; + + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenerating", + value: levelGenerating.filter((g) => g !== `${local.id}-video`), + module + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelGenResults, levelGenerating]); + const addPrompt = () => { setLocal(prev => ({ ...prev, @@ -150,7 +279,7 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise, module = "s module="speaking" /> - {generating && generating === "speakingScript" ? ( + {(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? ( ) : ( <> @@ -206,7 +335,7 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise, module = "s )} - {generating && generating === "video" && + {(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) && } diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx index d8a6e8ca..624dafbe 100644 --- a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx @@ -6,7 +6,7 @@ import Header from "../../Shared/Header"; import GenLoader from "../Shared/GenLoader"; import useSectionEdit from "../../Hooks/useSectionEdit"; import useExamEditorStore from "@/stores/examEditor"; -import { InteractiveSpeakingExercise } from "@/interfaces/exam"; +import { InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam"; import { BsFileText } from "react-icons/bs"; import { RiVideoLine } from 'react-icons/ri'; import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'; @@ -19,7 +19,7 @@ interface Props { } const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" }) => { - const {dispatch} = useExamEditorStore(); + const { dispatch } = useExamEditorStore(); const [local, setLocal] = useState(() => { const defaultPrompts = [ { text: "Hello my name is {avatar}, what is yours?", video_url: "" }, @@ -31,7 +31,7 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } const [currentVideoIndex, setCurrentVideoIndex] = useState(0); - const { generating, genResult , state} = useExamEditorStore( + const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore( (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! ); @@ -39,18 +39,48 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } sectionId, onSave: () => { setEditing(false); - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local, module } }); + + if (module === "level") { + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? local : ex + ) + }; + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + } else { + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: local, module } + }); + } + if (genResult) { dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, - module: module, + module, field: "genResult", value: undefined } }); } + + const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`); + if (module === "level" && speakingScript) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenResults", + value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`), + module + } + }); + } }, onDiscard: () => { setLocal({ @@ -63,36 +93,52 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } }); }, onPractice: () => { - const updatedExercise = { - ...state, - isPractice: !local.isPractice - }; - setLocal((prev) => ({...prev, isPractice: !local.isPractice})) - dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } }); + const updatedLocal = { ...local, isPractice: !local.isPractice }; + setLocal(updatedLocal); + + if (module === "level") { + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? updatedLocal : ex + ) + }; + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + } else { + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedLocal, module } + }); + } }, }); useEffect(() => { if (genResult && generating === "speakingScript") { - setEditing(true); - setLocal(prev => ({ - ...prev, + const updatedLocal = { + ...local, first_title: genResult.result[0].first_topic, second_title: genResult.result[0].second_topic, prompts: [ - prev.prompts[0], - prev.prompts[1], + local.prompts[0], + local.prompts[1], ...genResult.result[0].prompts.map((item: any) => ({ text: item, video_url: "" })) ] - })); + }; + setEditing(true); + setLocal(updatedLocal); + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, - module: module, + module, field: "generating", value: undefined } @@ -101,6 +147,96 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } // eslint-disable-next-line react-hooks/exhaustive-deps }, [genResult, generating]); + useEffect(() => { + if (genResult && generating === "video") { + const updatedLocal = { ...local, prompts: genResult.result[0].prompts }; + setLocal(updatedLocal); + + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedLocal, module } + }); + + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module, + field: "generating", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + useEffect(() => { + const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`); + const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`); + + if (speakingScript && isGenerating) { + const updatedLocal = { + ...local, + first_title: speakingScript.result[0].first_topic, + second_title: speakingScript.result[0].second_topic, + prompts: [ + local.prompts[0], + local.prompts[1], + ...speakingScript.result[0].prompts.map((item: any) => ({ + text: item, + video_url: "" + })) + ] + }; + setEditing(true); + setLocal(updatedLocal); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenerating", + value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`), + module + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelGenResults, levelGenerating]); + + useEffect(() => { + const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`); + const isGenerating = levelGenerating?.includes(`${local.id}-video`); + + if (speakingVideo && isGenerating) { + const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url }; + setLocal(updatedLocal); + + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? updatedLocal : ex + ) + }; + + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenerating", + value: levelGenerating.filter((g) => g !== `${local.id}-video`), + module + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelGenResults, levelGenerating]); + + const addPrompt = () => { setLocal(prev => ({ ...prev, @@ -159,7 +295,7 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } }; const handleNextVideo = () => { - setCurrentVideoIndex((prev) => + setCurrentVideoIndex((prev) => (prev < local.prompts.length - 1 ? prev + 1 : prev) ); }; @@ -179,7 +315,7 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } module="speaking" /> - {generating && generating === "speakingScript" ? ( + {(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? ( ) : ( <> @@ -287,8 +423,8 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } onClick={handlePrevVideo} disabled={currentVideoIndex === 0} className={`p-2 rounded-full ${currentVideoIndex === 0 - ? 'text-gray-400 cursor-not-allowed' - : 'text-gray-600 hover:bg-gray-100' + ? 'text-gray-400 cursor-not-allowed' + : 'text-gray-600 hover:bg-gray-100' }`} > @@ -300,8 +436,8 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } onClick={handleNextVideo} disabled={currentVideoIndex === local.prompts.length - 1} className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1 - ? 'text-gray-400 cursor-not-allowed' - : 'text-gray-600 hover:bg-gray-100' + ? 'text-gray-400 cursor-not-allowed' + : 'text-gray-600 hover:bg-gray-100' }`} > @@ -323,7 +459,7 @@ const Speaking1: React.FC = ({ sectionId, exercise, module = "speaking" } )} - {generating && generating === "video" && + {(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) && } diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx index 85918188..5b21fc69 100644 --- a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx @@ -1,7 +1,7 @@ import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; import { Card, CardContent } from "@/components/ui/card"; import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai'; -import { SpeakingExercise } from "@/interfaces/exam"; +import { LevelPart, SpeakingExercise } from "@/interfaces/exam"; import useExamEditorStore from "@/stores/examEditor"; import { useEffect, useState } from "react"; import useSectionEdit from "../../Hooks/useSectionEdit"; @@ -20,61 +20,104 @@ interface Props { module?: Module; } - const Speaking2: React.FC = ({ sectionId, exercise, module = "speaking" }) => { const { dispatch } = useExamEditorStore(); const [local, setLocal] = useState(exercise); - const { generating, genResult, state } = useExamEditorStore( - (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! - ); + const { sections } = useExamEditorStore((store) => store.modules[module]); + const section = sections.find((section) => section.sectionId === sectionId)!; + const { generating, genResult, state, levelGenResults, levelGenerating } = section; const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({ sectionId, onSave: () => { setEditing(false); - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local , module} }); + + if (module === "level") { + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? local : ex + ) + }; + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + } else { + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: local, module } + }); + } + if (genResult) { dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, - module: module, + module, field: "genResult", value: undefined } }); } + + const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`) + if (module === "level" && speakingScript) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenResults", + value: section!.levelGenResults.filter((res) => res.generating !== `${local.id ? `${local.id}-` : ''}speakingScript`), + module + } + }) + } }, onDiscard: () => { setLocal(exercise); }, onPractice: () => { - const updatedExercise = { - ...state, - isPractice: !local.isPractice - }; - setLocal((prev) => ({...prev, isPractice: !local.isPractice})) - dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } }); + const updatedLocal = { ...local, isPractice: !local.isPractice }; + setLocal(updatedLocal); + + if (module === "level") { + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? updatedLocal : ex + ) + }; + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + } else { + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedLocal, module } + }); + } }, }); - useEffect(() => { if (genResult && generating === "speakingScript") { - setEditing(true); - setLocal({ + const updatedLocal = { ...local, title: genResult.result[0].topic, text: genResult.result[0].question, prompts: genResult.result[0].prompts - }); + }; + setEditing(true); + setLocal(updatedLocal); dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, - module: module, + module, field: "generating", value: undefined } @@ -85,13 +128,19 @@ const Speaking2: React.FC = ({ sectionId, exercise, module = "speaking" } useEffect(() => { if (genResult && generating === "video") { - setLocal({...local, video_url: genResult.result[0].video_url}); - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult.result[0].video_url} , module} }); + const updatedLocal = { ...local, video_url: genResult.result[0].video_url }; + setLocal(updatedLocal); + + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedLocal, module } + }); + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, - module: module, + module, field: "generating", value: undefined } @@ -100,6 +149,62 @@ const Speaking2: React.FC = ({ sectionId, exercise, module = "speaking" } // eslint-disable-next-line react-hooks/exhaustive-deps }, [genResult, generating]); + useEffect(() => { + const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`); + const generating = levelGenerating.find((res) => res === `${local.id}-speakingScript`); + if (speakingScript && generating) { + const updatedLocal = { + ...local, + title: speakingScript.result[0].topic, + text: speakingScript.result[0].question, + prompts: speakingScript.result[0].prompts + }; + setEditing(true); + setLocal(updatedLocal); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenerating", + value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}speakingScript`), + module + } + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelGenResults, levelGenerating]); + + useEffect(() => { + const speakingVideo = levelGenResults.find((res) => res.generating === `${local.id}-video`); + const generating = levelGenerating.find((res) => res === `${local.id}-video`); + + if (speakingVideo && generating) { + const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url }; + setLocal(updatedLocal); + + const updatedState = { + ...state, + exercises: (state as LevelPart).exercises.map((ex) => + ex.id === local.id ? updatedLocal : ex + ) + }; + + dispatch({ + type: "UPDATE_SECTION_STATE", + payload: { sectionId, update: updatedState, module } + }); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, + field: "levelGenerating", + value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}video`), + module + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelGenResults, levelGenerating]); + const addPrompt = () => { setLocal(prev => ({ ...prev, @@ -143,7 +248,7 @@ const Speaking2: React.FC = ({ sectionId, exercise, module = "speaking" } <>
= ({ sectionId, exercise, module = "speaking" } module="speaking" />
- {generating && generating === "speakingScript" ? ( + {((generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`))) ? ( ) : ( <> @@ -263,14 +368,14 @@ const Speaking2: React.FC = ({ sectionId, exercise, module = "speaking" }
} - {generating && generating === "video" && + {((generating === "video") || (levelGenerating.find((g) => g === `${local.id}-video`) !== undefined)) && } diff --git a/src/components/ExamEditor/Exercises/Speaking/index.tsx b/src/components/ExamEditor/Exercises/Speaking/index.tsx index a54ee966..2f64faae 100644 --- a/src/components/ExamEditor/Exercises/Speaking/index.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/index.tsx @@ -8,28 +8,20 @@ import { Module } from "@/interfaces"; interface Props { sectionId: number; exercise: SpeakingExercise | InteractiveSpeakingExercise; - qId?: number; module: Module; } -const Speaking: React.FC = ({ sectionId, exercise, qId, module = "speaking" }) => { - const { dispatch } = useExamEditorStore(); +const Speaking: React.FC = ({ sectionId, module = "speaking" }) => { const { state } = useExamEditorStore( (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! ); - const onFocus = () => { - if (qId) { - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module, sectionId, field: "focusedExercise", value: { questionId: qId, id: exercise.id } } }) - } - } - return ( <> -
+
- {sectionId === 1 && } + {sectionId === 1 && } {sectionId === 2 && } {sectionId === 3 && }
diff --git a/src/components/ExamEditor/Exercises/Writing/index.tsx b/src/components/ExamEditor/Exercises/Writing/index.tsx index 231b6198..46fc87a6 100644 --- a/src/components/ExamEditor/Exercises/Writing/index.tsx +++ b/src/components/ExamEditor/Exercises/Writing/index.tsx @@ -100,7 +100,7 @@ const Writing: React.FC = ({ sectionId, exercise, module, index }) => { description='Generate or edit the instructions for the task' editing={editing} handleSave={handleSave} - handleDelete={handleDelete} + handleDelete={module == "level" ? handleDelete : undefined} handleEdit={handleEdit} handleDiscard={handleDiscard} handlePractice={handlePractice} diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx index a52ea650..639d67e4 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx @@ -51,6 +51,12 @@ const ListeningContext: React.FC = ({ sectionId, module, listeningSection }, }); + useEffect(()=> { + if (listeningPart.script == undefined) { + setScriptLocal(undefined); + } + }, [listeningPart]) + useEffect(() => { if (genResult && generating === "listeningScript") { setEditing(true); diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx index 5f1c7fd6..424bac59 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx @@ -55,6 +55,13 @@ const ReadingContext: React.FC = ({ sectionId, module, level = false }) = } }); + useEffect(()=> { + if (readingPart.text === undefined) { + setTitle(''); + setContent(''); + } + }, [readingPart]) + useEffect(() => { if (genResult && genResult.generating === "passage") { setEditing(true); diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/exercises.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/exercises.tsx index 93de63e7..94dcf00f 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/exercises.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/exercises.tsx @@ -7,7 +7,9 @@ import TrueFalse from "../../Exercises/TrueFalse"; import fillBlanks from "./fillBlanks"; import MatchSentences from "../../Exercises/MatchSentences"; import Writing from "../../Exercises/Writing"; -import Speaking from "../../Exercises/Speaking"; +import Speaking2 from "../../Exercises/Speaking/Speaking2"; +import Speaking1 from "../../Exercises/Speaking/Speaking1"; +import InteractiveSpeaking from "../../Exercises/Speaking/InteractiveSpeaking"; const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => { const items: ExerciseItem[] = exercises.map((exercise, index) => { @@ -79,41 +81,42 @@ const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseIte ), content: }; - case "speaking": + case "speaking": return { + exerciseId: exercise.id, id: index.toString(), sectionId, label: ( ), - content: + content: }; case "interactiveSpeaking": + const content = exercise.sectionId === 1 ? : + return { + exerciseId: exercise.id, id: index.toString(), sectionId, label: ( ), - content: + content: content }; default: return {} as unknown as ExerciseItem; } }).filter(isExerciseItem); - /*return mappedItems.filter((item): item is ExerciseItem => - item !== null && isExerciseItem(item) - );*/ return items; }; diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx index df3a01a3..6b4e4e2f 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx @@ -1,6 +1,6 @@ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import SortableSection from "../../Shared/SortableSection"; -import { Exercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; +import { Exercise, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import ExerciseItem from "./types"; import Dropdown from "@/components/Dropdown"; import useExamEditorStore from "@/stores/examEditor"; @@ -20,6 +20,7 @@ import React from "react"; import getExerciseItems from "./exercises"; import { Action } from "@/stores/examEditor/reducers"; import { writingTask } from "@/stores/examEditor/sections"; +import { createSpeakingExercise } from "./speaking"; interface QuestionItemsResult { @@ -119,14 +120,14 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { sectionId, module: "level", update: { - exercises: [...(sectionState as ExamPart).exercises, - ...results.map((res)=> { - return { - ...writingTask(res.generating === "writing_letter" ? 1 : 2), - prompt: res.result[0].prompt, - variant: res.generating === "writing_letter" ? "letter" : "essay" - } as WritingExercise; - }) + exercises: [...(sectionState as ExamPart).exercises, + ...results.map((res) => { + return { + ...writingTask(res.generating === "writing_letter" ? 1 : 2), + prompt: res.result[0].prompt, + variant: res.generating === "writing_letter" ? "letter" : "essay" + } as WritingExercise; + }) ] } } @@ -156,6 +157,46 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); + useEffect(() => { + if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("speaking"))) { + const results = levelGenResults.filter(res => res.generating.startsWith("speaking")); + const updates = [ + { + type: "UPDATE_SECTION_STATE", + payload: { + sectionId, + module: "level", + update: { + exercises: [...(sectionState as ExamPart).exercises, + ...results.map(createSpeakingExercise) + ] + } + } + }, + { + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "levelGenerating", + value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g)) + } + }, + { + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "levelGenResults", + value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating)) + } + } + ] as Action[]; + + updates.forEach(update => dispatch(update)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); const currentSection = sections.find((s) => s.sectionId === sectionId)!; @@ -199,7 +240,13 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { const filteredItems = (questions.items ?? []).filter(isValidItem); // ############################################################################# - console.log(levelGenerating); + + const onFocus = (questionId: string, id: string | undefined) => { + if (id) { + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id} } }) + } + } + return ( = ({ sectionId }) => { customTitle={item.label} contentWrapperClassName="rounded-xl" > -
+
onFocus(item.id, item.exerciseId)}> {item.content}
@@ -237,9 +284,9 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { {currentModule === "level" && ( <> { - questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing")) && generating !== "exercises" + questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises" && background(Generated exercises will appear here!)} - {levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing")) && } + {levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && } ) } diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/speaking.ts b/src/components/ExamEditor/SectionRenderer/SectionExercises/speaking.ts new file mode 100644 index 00000000..5be387e9 --- /dev/null +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/speaking.ts @@ -0,0 +1,47 @@ +import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam"; +import { speakingTask } from "@/stores/examEditor/sections"; + +export const createSpeakingExercise = (res: any) => { + const taskNumber = Number(res.generating.split("_")[1]); + const baseExercise = speakingTask(taskNumber); + return { + ...baseExercise, + ...getSpeakingTaskData(taskNumber, res.result[0]) + } as SpeakingExercise | InteractiveSpeakingExercise; +}; + +const getSpeakingTaskData = (taskNumber: number, data: any) => { + switch (taskNumber) { + case 1: + return { + first_title: data.first_topic, + second_title: data.second_topic, + prompts: [ + ...data.prompts.map((item: any) => ({ + text: item, + video_url: "" + })) + ], + sectionId: 1, + }; + case 2: + return { + title: data.topic, + text: data.question, + prompts: data.prompts, + sectionId: 2, + type: "speaking" + }; + case 3: + return { + title: data.topic, + prompts: data.questions.map((item: any) => ({ + text: item || "", + video_url: "" + })), + sectionId: 3, + }; + default: + return data; + } +}; \ No newline at end of file diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts b/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts index 50738949..cd2ca438 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts @@ -3,6 +3,7 @@ export default interface ExerciseItem { sectionId: number; label: React.ReactNode; content: React.ReactNode; + exerciseId?: string; } export function isExerciseItem(item: unknown): item is ExerciseItem { diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/updatePickerExercises.ts b/src/components/ExamEditor/SectionRenderer/SectionExercises/updatePickerExercises.ts new file mode 100644 index 00000000..f7b6114c --- /dev/null +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/updatePickerExercises.ts @@ -0,0 +1,66 @@ +import { Action } from "@/stores/examEditor/reducers"; +import { ExamPart, Generating } from "@/stores/examEditor/types"; +import { createSpeakingExercise } from "./speaking"; +import { writingTask } from "@/stores/examEditor/sections"; +import { WritingExercise } from "@/interfaces/exam"; + +const getResults = (results: any[], type: 'writing' | 'speaking') => { + return results.map((res) => { + if (type === 'writing') { + return { + ...writingTask(res.generating === "writing_letter" ? 1 : 2), + prompt: res.result[0].prompt, + variant: res.generating === "writing_letter" ? "letter" : "essay" + } as WritingExercise; + } + return createSpeakingExercise(res); + }); +}; + +const updates = ( + results: any[], + sectionState: ExamPart, + sectionId: number, + currentModule: string, + levelGenerating: any[], + levelGenResults: any[], + type: 'writing' | 'speaking' +): Action[] => { + return [ + { + type: "UPDATE_SECTION_STATE", + payload: { + sectionId, + module: "level", + update: { + exercises: [ + ...(sectionState as ExamPart).exercises, + ...getResults(results, type) + ] + } + } + }, + { + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "levelGenerating", + value: levelGenerating?.filter(g => + !results.flatMap(res => res.generating as Generating).includes(g) + ) + } + }, + { + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "levelGenResults", + value: levelGenResults.filter(res => + !results.flatMap(res => res.generating as Generating).includes(res.generating) + ) + } + } + ] as Action[]; +}; \ No newline at end of file diff --git a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts index be2a9c0d..fbd9cbe8 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts +++ b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts @@ -37,7 +37,7 @@ export function generate( dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { sectionId : sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate } + payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate } }); }; @@ -54,7 +54,7 @@ export function generate( dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults } + payload: { sectionId: level ? levelSectionId! : sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults } }); }; diff --git a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx index 0af56197..5e0f115b 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx @@ -12,16 +12,17 @@ interface Props { genType: Generating; generateFnc: (sectionId: number) => void className?: string; - levelId?: number; level?: boolean; } -const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, className, level = false, levelId }) => { - const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == levelId ? levelId : sectionId)); +const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, className, level = false }) => { + const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId)); const [loading, setLoading] = useState(false); const generating = section?.generating; + const genResult = section?.genResult; const levelGenerating = section?.levelGenerating; + const levelGenResults = section?.levelGenResults; useEffect(()=> { const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType); @@ -29,7 +30,7 @@ const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, setLoading(gen); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [generating, levelGenerating]) + }, [generating, levelGenerating, levelGenResults, genResult]) if (section === undefined) return <>; @@ -42,7 +43,7 @@ const GenerateBtn: React.FC = ({ module, sectionId, genType, generateFnc, className )} disabled={loading} - onClick={loading ? () => { } : () => generateFnc(levelId ? levelId : sectionId)} + onClick={loading ? () => { } : () => generateFnc(sectionId)} > {loading ? (
diff --git a/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx b/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx index eeac2904..1e32b0b3 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx @@ -3,6 +3,7 @@ import { Module } from "@/interfaces"; import useExamEditorStore from "@/stores/examEditor"; import Dropdown from "./SettingsDropdown"; import { LevelSectionSettings } from "@/stores/examEditor/types"; +import { LevelPart } from '@/interfaces/exam'; interface Props { module: Module; @@ -19,13 +20,15 @@ const SectionPicker: React.FC = ({ }) => { const { dispatch } = useExamEditorStore(); const [selectedValue, setSelectedValue] = React.useState(undefined); - + const sectionState = useExamEditorStore(state => state.modules["level"].sections.find((s) => s.sectionId === sectionId) ); - + + const state = sectionState?.state as LevelPart; + if (sectionState === undefined) return null; - + const { readingSection, listeningSection } = sectionState; const currentValue = selectedValue ?? (module === "reading" ? readingSection : listeningSection); const options = module === "reading" ? [1, 2, 3] : [1, 2, 3, 4]; @@ -34,16 +37,44 @@ const SectionPicker: React.FC = ({ const handleSectionChange = (value: number) => { const newValue = currentValue === value ? undefined : value; setSelectedValue(newValue); - + let update = {}; + if (module == "reading") { + update = { + text: undefined + } + } else { + if (state.audio?.source) { + URL.revokeObjectURL(state.audio.source) + } + update = { + audio: undefined, + script: undefined, + } + } + dispatch({ - type: "UPDATE_SECTION_SINGLE_FIELD", + type: "UPDATE_SECTION_STATE", payload: { sectionId, module: "level", - field: module === "reading" ? "readingSection" : "listeningSection", - value: newValue + update: { + ...state, + ...update + } } - }); + }) + + setTimeout(() => { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: "level", + field: module === "reading" ? "readingSection" : "listeningSection", + value: newValue + } + }); + }, 500); }; const getTitle = () => { @@ -69,7 +100,7 @@ const SectionPicker: React.FC = ({ className={` flex items-center space-x-3 font-semibold cursor-pointer p-2 rounded transition-colors duration-200 - ${currentValue === num + ${currentValue === num ? `bg-ielts-${module}/90 text-white` : `hover:bg-ielts-${module}/70 text-gray-700`} `} @@ -81,7 +112,7 @@ const SectionPicker: React.FC = ({ {}} + onChange={() => { }} className={` h-5 w-5 cursor-pointer accent-ielts-${module} diff --git a/src/components/ExamEditor/SettingsEditor/level.tsx b/src/components/ExamEditor/SettingsEditor/level.tsx index cca0c2eb..593fabff 100644 --- a/src/components/ExamEditor/SettingsEditor/level.tsx +++ b/src/components/ExamEditor/SettingsEditor/level.tsx @@ -55,47 +55,180 @@ const LevelSettings: React.FC = () => { const readingSection = section.readingSection; const listeningSection = section.listeningSection; - const canPreview = currentSection.exercises.length > 0; + const canPreviewOrSubmit = sections.length > 0 && sections.some(s => { + const part = s.state as LevelPart; + return part.exercises.length > 0 && part.exercises.every((exercise) => { + if (exercise.type === 'speaking') { + return exercise.title !== '' && + exercise.text !== '' && + exercise.video_url !== '' && + exercise.prompts.every(prompt => prompt !== ''); + } else if (exercise.type === 'interactiveSpeaking') { + if ('first_title' in exercise && 'second_title' in exercise) { + return exercise.first_title !== '' && + exercise.second_title !== '' && + exercise.prompts.every(prompt => prompt.video_url !== '') && + exercise.prompts.length > 2; + } + return exercise.title !== '' && + exercise.prompts.every(prompt => prompt.video_url !== ''); + } + return true; + }); + }); - const submitLevel = () => { + const submitLevel = async () => { if (title === "") { toast.error("Enter a title for the exam!"); return; } - const exam: LevelExam = { - parts: sections.map((s) => { - const part = s.state as LevelPart; - return { - ...part, - intro: localSettings.currentIntro, - category: localSettings.category - }; - }), - isDiagnostic: false, - minTimer, - module: "level", - id: title, - difficulty, - private: isPrivate, - }; - axios.post(`/api/exam/level`, exam) - .then((result) => { - playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); - }) - .catch((error) => { - console.log(error); - toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); - }) - } + const partsWithMissingAudio = sections.some(s => { + const part = s.state as LevelPart; + return part.audio && !part.audio.source; + }); + + if (partsWithMissingAudio) { + toast.error("There are parts with missing audio recordings. Either generate them or remove the listening sections."); + return; + } + + + try { + const audioFormData = new FormData(); + const videoFormData = new FormData(); + const audioMap = new Map(); + const videoMap = new Map(); + + const partsWithAudio = sections.filter(s => (s.state as LevelPart).audio?.source); + await Promise.all( + partsWithAudio.map(async (section) => { + const levelPart = section.state as LevelPart; + const blobUrl = levelPart.audio!.source; + const response = await fetch(blobUrl); + const blob = await response.blob(); + audioFormData.append('file', blob, 'audio.mp3'); + audioMap.set(section.sectionId, blobUrl); + }) + ); + + await Promise.all( + sections.flatMap(async (section) => { + const levelPart = section.state as LevelPart; + return Promise.all( + levelPart.exercises.map(async (exercise, exerciseIndex) => { + if (exercise.type === "speaking") { + if (exercise.video_url) { + const response = await fetch(exercise.video_url); + const blob = await response.blob(); + videoFormData.append('file', blob, 'video.mp4'); + videoMap.set(`${section.sectionId}-${exerciseIndex}`, exercise.video_url); + } + } else if (exercise.type === "interactiveSpeaking") { + await Promise.all( + exercise.prompts.map(async (prompt, promptIndex) => { + if (prompt.video_url) { + const response = await fetch(prompt.video_url); + const blob = await response.blob(); + videoFormData.append('file', blob, 'video.mp4'); + videoMap.set(`${section.sectionId}-${exerciseIndex}-${promptIndex}`, prompt.video_url); + } + }) + ); + } + }) + ); + }) + ); + + const [audioUrls, videoUrls] = await Promise.all([ + audioMap.size > 0 + ? axios.post('/api/storage', audioFormData, { + params: { directory: 'listening_recordings' }, + headers: { 'Content-Type': 'multipart/form-data' } + }).then(response => response.data.urls) + : [], + videoMap.size > 0 + ? axios.post('/api/storage', videoFormData, { + params: { directory: 'speaking_videos' }, + headers: { 'Content-Type': 'multipart/form-data' } + }).then(response => response.data.urls) + : [] + ]); + + const exam: LevelExam = { + parts: sections.map((s) => { + const part = s.state as LevelPart; + const audioIndex = Array.from(audioMap.entries()) + .findIndex(([id]) => id === s.sectionId); + const updatedExercises = part.exercises.map((exercise, exerciseIndex) => { + if (exercise.type === "speaking") { + const videoIndex = Array.from(videoMap.entries()) + .findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}`); + return { + ...exercise, + video_url: videoIndex !== -1 ? videoUrls[videoIndex] : exercise.video_url + }; + } else if (exercise.type === "interactiveSpeaking") { + const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => { + const videoIndex = Array.from(videoMap.entries()) + .findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}-${promptIndex}`); + return { + ...prompt, + video_url: videoIndex !== -1 ? videoUrls[videoIndex] : prompt.video_url + }; + }); + return { + ...exercise, + prompts: updatedPrompts + }; + } + return exercise; + }); + return { + ...part, + audio: part.audio ? { + ...part.audio, + source: audioIndex !== -1 ? audioUrls[audioIndex] : part.audio.source + } : undefined, + exercises: updatedExercises, + intro: s.settings.currentIntro, + category: s.settings.category + }; + }).filter(part => part.exercises.length > 0), + isDiagnostic: false, + minTimer, + module: "level", + id: title, + difficulty, + private: isPrivate, + }; + + const result = await axios.post('/api/exam/level', exam); + playSound("sent"); + toast.success(`Submitted Exam ID: ${result.data.id}`); + + Array.from(audioMap.values()).forEach(url => { + URL.revokeObjectURL(url); + }); + Array.from(videoMap.values()).forEach(url => { + URL.revokeObjectURL(url); + }); + + } catch (error: any) { + console.error('Error submitting exam:', error); + toast.error( + "Something went wrong while submitting, please try again later." + ); + } + }; const preview = () => { setExam({ parts: sections.map((s) => { - const exercise = s.state as LevelPart; + const part = s.state as LevelPart; return { - ...exercise, + ...part, intro: s.settings.currentIntro, category: s.settings.category }; @@ -115,7 +248,6 @@ const LevelSettings: React.FC = () => { } const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises.find((ex) => ex.id === focusedExercise.id) as SpeakingExercise | InteractiveSpeakingExercise; - return ( { module="level" introPresets={[]} preview={preview} - canPreview={canPreview} - canSubmit={canPreview} + canPreview={canPreviewOrSubmit} + canSubmit={canPreviewOrSubmit} submitModule={submitLevel} >
@@ -211,7 +343,6 @@ const LevelSettings: React.FC = () => { />
- {/*
{ open={localSettings.isSpeakingDropdownOpen} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} > - updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} - > -
+
+ updateLocalAndScheduleGlobal({ isSpeakingExercisesOpen: isOpen }, false)} + > { levelSectionId={focusedSection} level /> -
- - - {speakingExercise !== undefined && - updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} - > - + - } + + {speakingExercise !== undefined && + updateLocalAndScheduleGlobal({ isConfigureExercisesOpen: isOpen }, false)} + > +
+ +
+
+ } +
- */}
); }; diff --git a/src/components/ExamEditor/SettingsEditor/listening/components.tsx b/src/components/ExamEditor/SettingsEditor/listening/components.tsx index fafa2ffb..7e66da3d 100644 --- a/src/components/ExamEditor/SettingsEditor/listening/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/components.tsx @@ -184,19 +184,16 @@ const ListeningComponents: React.FC = ({ currentSection, localSettings, u disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0} contentWrapperClassName={level ? `border border-ielts-listening` : ''} > -
- +
+ Generate audio recording for this section -
- -
+
diff --git a/src/components/ExamEditor/SettingsEditor/speaking/components.tsx b/src/components/ExamEditor/SettingsEditor/speaking/components.tsx index 604cc4f0..524cd952 100644 --- a/src/components/ExamEditor/SettingsEditor/speaking/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/speaking/components.tsx @@ -1,6 +1,6 @@ import useExamEditorStore from "@/stores/examEditor"; import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { generate } from "../Shared/Generate"; import Dropdown from "../Shared/SettingsDropdown"; import Input from "@/components/Low/Input"; @@ -11,6 +11,7 @@ import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/inte import { toast } from "react-toastify"; import { generateVideos } from "../Shared/generateVideos"; import { Module } from "@/interfaces"; +import useCanGenerate from "./useCanGenerate"; export interface Avatar { name: string; @@ -23,16 +24,19 @@ interface Props { section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart; level?: boolean; module?: Module; + id?: string; + sectionId?: number; } -const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndScheduleGlobal, section, level, module = "speaking" }) => { +const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndScheduleGlobal, section, level = false, module = "speaking", id, sectionId }) => { - const { currentModule, speakingAvatars, dispatch } = useExamEditorStore(); - const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule]) + const { currentModule, speakingAvatars, dispatch, modules } = useExamEditorStore(); + const { focusedSection, difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule]) + const state = sections.find((s) => s.sectionId === sectionId); const [selectedAvatar, setSelectedAvatar] = useState(null); - const generateScript = useCallback((sectionId: number) => { + const generateScript = useCallback((scriptSectionId: number) => { const queryParams: { difficulty: string; first_topic?: string; @@ -40,7 +44,7 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche topic?: string; } = { difficulty }; - if (sectionId === 1) { + if (scriptSectionId === 1) { if (localSettings.speakingTopic) { queryParams['first_topic'] = localSettings.speakingTopic; } @@ -52,17 +56,16 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche queryParams['topic'] = localSettings.speakingTopic; } } - generate( - sectionId, - currentModule, - "speakingScript", + level ? section.sectionId! : focusedSection, + "speaking", + `${id ? `${id}-` : ''}speakingScript`, { method: 'GET', queryParams }, (data: any) => { - switch (sectionId) { + switch (level ? section.sectionId! : focusedSection) { case 1: return [{ prompts: data.questions, @@ -84,10 +87,12 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche default: return [data]; } - } + }, + sectionId, + level ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings, difficulty]); + + }, [difficulty, level, section.sectionId, focusedSection, id, sectionId, localSettings.speakingTopic, localSettings.speakingSecondTopic]); const onTopicChange = useCallback((speakingTopic: string) => { updateLocalAndScheduleGlobal({ speakingTopic }); @@ -97,39 +102,33 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche updateLocalAndScheduleGlobal({ speakingSecondTopic }); }, [updateLocalAndScheduleGlobal]); - const canGenerate = section && (() => { - switch (focusedSection) { - case 1: { - const currentSection = section as InteractiveSpeakingExercise; - return currentSection.first_title !== "" && - currentSection.second_title !== "" && - currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2; - } - case 2: { - const currentSection = section as SpeakingExercise; - return currentSection.title !== "" && - currentSection.text !== "" && - currentSection.prompts.every(prompt => prompt !== ""); - } - case 3: { - const currentSection = section as InteractiveSpeakingExercise; - return currentSection.title !== "" && - currentSection.prompts.every(prompt => prompt.text !== ""); - } - default: - return false; + const canGenerate = useCanGenerate({ + section, + sections, + id, + focusedSection + }); + + useEffect(() => { + if (!canGenerate) { + updateLocalAndScheduleGlobal({ isGenerateVideoOpen: false }, false); } - })(); + }, [canGenerate, updateLocalAndScheduleGlobal]); const generateVideoCallback = useCallback((sectionId: number) => { - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "video" } }) + if (level) { + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: "level", field: "levelGenerating", value: [...state!.levelGenerating, `${id ? `${id}-` : ''}video`] } }) + } else { + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId: focusedSection, module: "speaking", field: "generating", value: "video" } }) + } + generateVideos( section as InteractiveSpeakingExercise | SpeakingExercise, - sectionId, + level ? section.sectionId! : focusedSection, selectedAvatar, speakingAvatars ).then((results) => { - switch (sectionId) { + switch (level ? section.sectionId! : focusedSection) { case 1: case 3: { const interactiveSection = section as InteractiveSpeakingExercise; @@ -137,22 +136,40 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche ...prompt, video_url: results[index].url || '' })); - dispatch({ - type: "UPDATE_SECTION_SINGLE_FIELD", payload: { - sectionId, module: currentModule, field: "genResult", value: - { generating: "video", result: [{ prompts: updatedPrompts }], module: module } - } - }) + if (level) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, field: "levelGenResults", value: [...state!.levelGenResults, + { generating: `${id ? `${id}-` : ''}video`, result: [{ prompts: updatedPrompts }] }], module: "level" + } + }) + } else { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: focusedSection, module: "speaking", field: "genResult", value: + { generating: "video", result: [{ prompts: updatedPrompts }], module: module } + } + }) + } break; } case 2: { if (results[0]?.url) { - dispatch({ - type: "UPDATE_SECTION_SINGLE_FIELD", payload: { - sectionId, module: currentModule, field: "genResult", value: - { generating: "video", result: [{ video_url: results[0].url }], module: module } - } - }) + if (level) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, field: "levelGenResults", value: [...state!.levelGenResults, + { generating: `${id ? `${id}-` : ''}video`, result: [{ video_url: results[0].url }] }], module: "level" + } + }) + } else { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: focusedSection, module, field: "genResult", value: + { generating: 'video', result: [{ video_url: results[0].url }], module: "speaking" } + } + }) + } } break; } @@ -160,8 +177,10 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche }).catch((error) => { toast.error("Failed to generate the video, try again later!") }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAvatar, section]); + + }, [level, section, focusedSection, selectedAvatar, speakingAvatars, dispatch, module, state, id]); + + const secId = level ? section.sectionId! : focusedSection; return ( <> @@ -173,11 +192,11 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche contentWrapperClassName={level ? `border border-ielts-speaking` : ''} > -
+
- + = ({ localSettings, updateLocalAndSche value={localSettings.speakingTopic} />
- {focusedSection === 1 && + {secId === 1 &&
= ({ localSettings, updateLocalAndSche />
} -
+
@@ -216,6 +236,7 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche open={localSettings.isGenerateVideoOpen} disabled={!canGenerate} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-speaking` : ''} >
@@ -255,9 +276,10 @@ const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndSche
diff --git a/src/components/ExamEditor/SettingsEditor/speaking/useCanGenerate.tsx b/src/components/ExamEditor/SettingsEditor/speaking/useCanGenerate.tsx new file mode 100644 index 00000000..72237a4c --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/speaking/useCanGenerate.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from 'react'; +import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam"; +import { Section } from '@/stores/examEditor/types'; + +interface CheckGenerateProps { + section: Section | null; + sections: Array<{ sectionId: number; state: Section }>; + id?: string; + focusedSection: number; +} + +const useCanGenerate = ({ section, sections, id, focusedSection }: CheckGenerateProps) => { + const checkCanGenerate = useCallback(() => { + if (!section) return false; + + + const exercise = id + ? (sections.find(s => s.sectionId === 1)?.state as LevelPart) + ?.exercises?.find(ex => ex.id === id) ?? section + : section; + + const sectionId = id ? (exercise as SpeakingExercise | InteractiveSpeakingExercise).sectionId : focusedSection; + + switch (sectionId) { + case 1: { + const currentSection = exercise as InteractiveSpeakingExercise; + return currentSection.first_title && + currentSection.second_title && + currentSection.prompts?.length > 2 && + currentSection.prompts.every(prompt => prompt.text) + ; + } + case 2: { + const currentSection = exercise as SpeakingExercise; + return currentSection.title && + currentSection.text && + currentSection.prompts?.length > 0 && + currentSection.prompts.every(prompt => prompt) + ; + } + case 3: { + const currentSection = exercise as InteractiveSpeakingExercise; + return currentSection.title && + currentSection.prompts?.length > 0 && + currentSection.prompts.every(prompt => prompt.text) + ; + } + default: + return false; + } + }, [section, sections, id, focusedSection]); + + const [canGenerate, setCanGenerate] = useState(checkCanGenerate()); + + useEffect(() => { + setCanGenerate(checkCanGenerate()); + }, [checkCanGenerate, section]); + + return canGenerate; +}; + +export default useCanGenerate; diff --git a/src/stores/examEditor/sections.ts b/src/stores/examEditor/sections.ts index 5a25e547..48c9cd5d 100644 --- a/src/stores/examEditor/sections.ts +++ b/src/stores/examEditor/sections.ts @@ -35,7 +35,7 @@ export const listeningSection = (task: number) => { }; export const speakingTask = (task: number) => { - if (task === 3) { + if ([1,3].includes(task)) { return { id: v4(), type: "interactiveSpeaking", diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index 91f032f7..adf4d4c0 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -72,6 +72,8 @@ export interface LevelSectionSettings extends SectionSettings { speakingSecondTopic?: string; isSpeakingTopicOpen: boolean; isGenerateVideoOpen: boolean; + isSpeakingExercisesOpen: boolean; + isConfigureExercisesOpen: boolean; // section picker isReadingPickerOpen: boolean; @@ -80,6 +82,7 @@ export interface LevelSectionSettings extends SectionSettings { export type Context = "passage" | "video" | "audio" | "listeningScript" | "speakingScript" | "writing"; export type Generating = Context | "exercises" | string | undefined; +export type LevelGenResults = {generating: string, result: Record[], module: Module}; export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise; export type ExamPart = ListeningPart | ReadingPart | LevelPart; @@ -91,7 +94,7 @@ export interface SectionState { generating: Generating; genResult: {generating: string, result: Record[], module: Module} | undefined; levelGenerating: Generating[]; - levelGenResults: {generating: string, result: Record[], module: Module}[]; + levelGenResults: LevelGenResults[]; focusedExercise?: {questionId: number; id: string} | undefined; writingSection?: number; speakingSection?: number;