diff --git a/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx index cdee19cc..dd9d854d 100644 --- a/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx +++ b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx @@ -12,7 +12,7 @@ interface Props { const ReferenceViewer: React.FC = ({ showReference, selectedReference, options, setShowReference, headings = true}) => (
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/Script/index.tsx b/src/components/ExamEditor/Exercises/Script/index.tsx index 3fc586a1..76064c8f 100644 --- a/src/components/ExamEditor/Exercises/Script/index.tsx +++ b/src/components/ExamEditor/Exercises/Script/index.tsx @@ -31,7 +31,7 @@ interface MessageWithPosition extends ScriptLine { interface Props { section: number; editing?: boolean; - local: Script; + local?: Script; setLocal: (script: Script) => void; } @@ -41,14 +41,32 @@ const colorOptions = [ ]; const ScriptEditor: React.FC = ({ section, editing = false, local, setLocal }) => { + const isConversation = [1, 3].includes(section); + const speakerCount = section === 1 ? 2 : 4; + + if (local === undefined) { + if (isConversation) { + setLocal([]); + } else { + setLocal(''); + } + } const [selectedSpeaker, setSelectedSpeaker] = useState(''); const [newMessage, setNewMessage] = useState(''); - const speakerCount = section === 1 ? 2 : 4; - const [speakers, setSpeakers] = useState(() => { + if (local === undefined) { + return Array.from({ length: speakerCount }, (_, index) => ({ + id: index, + name: '', + gender: 'male', + color: colorOptions[index], + position: index % 2 === 0 ? 'left' : 'right' + })); + } + const existingScript = local as ScriptLine[]; const existingSpeakers = new Set(); const speakerGenders = new Map(); @@ -226,7 +244,7 @@ const ScriptEditor: React.FC = ({ section, editing = false, local, setLoc
{editing && ( -
+

Edit Conversation

{speakers.map((speaker, index) => ( diff --git a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx new file mode 100644 index 00000000..ac86f827 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx @@ -0,0 +1,315 @@ +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { BiQuestionMark } from 'react-icons/bi'; +import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai'; +import { Tooltip } from "react-tooltip"; +import Header from "../../Shared/Header"; +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 { BsFileText } from "react-icons/bs"; +import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; +import { RiVideoLine } from "react-icons/ri"; + +interface Props { + sectionId: number; + exercise: InteractiveSpeakingExercise; +} + +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)! + ); + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); + }, + onDiscard: () => { + setLocal(exercise); + }, + onMode: () => { }, + }); + + useEffect(() => { + if (genResult && generating === "context") { + setEditing(true); + setLocal({ + ...local, + title: genResult[0].title, + prompts: genResult[0].prompts.map((item: any) => ({ + text: item || "", + video_url: "" + })) + }); + + 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, + prompts: [...prev.prompts, { text: "", video_url: "" }] + })); + }; + + const removePrompt = (index: number) => { + setLocal(prev => ({ + ...prev, + prompts: prev.prompts.filter((_, i) => i !== index) + })); + }; + + const updatePrompt = (index: number, text: string) => { + setLocal(prev => { + const newPrompts = [...prev.prompts]; + newPrompts[index] = { ...newPrompts[index], text }; + return { ...prev, prompts: newPrompts }; + }); + }; + + 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 ( + <> +
+
+
+ {generating && generating === "context" ? ( + + ) : ( + <> + {editing ? ( + <> + {local.prompts.every((p) => p.video_url !== "") && ( + + +
+
+
+ +

Videos

+
+
+ + + {currentVideoIndex + 1} / {local.prompts.length} + + +
+
+
+
+ +
+
+
+
+
+ )} + {generating && generating === "media" && + + } + + +
+

Title

+ setLocal(prev => ({ ...prev, title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the title" + /> +
+
+
+ + +
+

Questions

+
+ +
+ {local.prompts.length === 0 ? ( +
+

No questions added yet

+
+ ) : ( + local.prompts.map((prompt, index) => ( + + +
+
+

Question {index + 1}

+ +
+ updatePrompt(index, text)} + className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white" + placeholder={`Enter question ${index + 1}`} + /> +
+
+
+ )) + )} +
+ +
+ +
+
+
+ + ) : isUnedited ? ( +

+ Generate or edit the questions! +

+ ) : ( +
+ + +
+
+ +

Title

+
+
+

{local.title || 'Untitled'}

+
+
+
+
+ + +
+
+ +

Questions

+
+
+ {local.prompts + .filter(prompt => prompt.text !== "") + .map((prompt, index) => ( +
+

Question {index + 1}

+

{prompt.text}

+
+ )) + } +
+
+
+
+
+ )} + + )} + + ); +}; + +export default InteractiveSpeaking; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx new file mode 100644 index 00000000..9cb8fe93 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx @@ -0,0 +1,364 @@ +import React, { useEffect, useState } from 'react'; +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai'; +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 { BsFileText } from "react-icons/bs"; +import { RiVideoLine } from 'react-icons/ri'; +import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'; + +interface Props { + sectionId: number; + exercise: InteractiveSpeakingExercise; +} + +const Speaking1: React.FC = ({ sectionId, exercise }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const [local, setLocal] = useState(() => { + const defaultPrompts = [ + { text: "Hello my name is {avatar}, what is yours?", video_url: "" }, + { text: "Do you work or do you study?", video_url: "" }, + ...exercise.prompts.slice(2) + ]; + return { ...exercise, prompts: defaultPrompts }; + }); + + const [currentVideoIndex, setCurrentVideoIndex] = useState(0); + + const { generating, genResult } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); + }, + onDiscard: () => { + setLocal({ + ...exercise, + prompts: [ + { text: "Hello my name is {avatar}, what is yours?", video_url: "" }, + { text: "Do you work or do you study?", video_url: "" }, + ...exercise.prompts.slice(2) + ] + }); + }, + onMode: () => { }, + }); + + useEffect(() => { + if (genResult && generating === "context") { + setEditing(true); + setLocal(prev => ({ + ...prev, + first_title: genResult[0].first_topic, + second_title: genResult[0].second_topic, + prompts: [ + prev.prompts[0], + prev.prompts[1], + ...genResult[0].prompts.map((item: any) => ({ + text: item, + video_url: "" + })) + ] + })); + + 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, + prompts: [...prev.prompts, { text: "", video_url: "" }] + })); + }; + + const removePrompt = (index: number) => { + if (index < 2) return; + setLocal(prev => ({ + ...prev, + prompts: prev.prompts.filter((_, i) => i !== index) + })); + }; + + const updatePrompt = (index: number, text: string) => { + if (index < 2) return; + setLocal(prev => { + const newPrompts = [...prev.prompts]; + newPrompts[index] = { ...newPrompts[index], text }; + return { ...prev, prompts: newPrompts }; + }); + }; + + 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 ( + <> +
+
+
+ {generating && generating === "context" ? ( + + ) : ( + <> + {editing ? ( + <> + + +
+
+ +

Titles

+
+
+
+

First Title

+ setLocal(prev => ({ ...prev, first_title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the first title" + /> +
+
+

Second Title

+ setLocal(prev => ({ ...prev, second_title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the second title" + /> +
+
+
+
+
+ + +
+

Questions

+
+ +
+ {local.prompts.length === 2 ? ( +
+

No questions added yet

+
+ ) : ( + local.prompts.slice(2).map((prompt, index) => ( + + +
+
+

Question {index + 1}

+ +
+ updatePrompt(index + 2, text)} + className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white" + placeholder={`Enter question ${index + 1}`} + /> +
+
+
+ )) + )} +
+ +
+ +
+
+
+ + ) : isUnedited ? ( +

+ Generate or edit the questions! +

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

Videos

+
+
+ + + {currentVideoIndex + 1} / {local.prompts.length} + + +
+
+
+
+ +
+
+
+
+
+ )} + {generating && generating === "media" && + + } + + +
+
+ +

Titles

+
+
+
+

First Title

+
+

{local.first_title || 'No first title'}

+
+
+
+

Second Title

+
+

{local.second_title || 'No second title'}

+
+
+
+
+
+
+ + +
+
+ +

Questions

+
+
+ {local.prompts.slice(2) + .filter(prompt => prompt.text !== "") + .map((prompt, index) => ( +
+

Question {index + 1}

+

{prompt.text}

+
+ )) + } +
+
+
+
+
+ )} + + )} + + ); +}; + +export default Speaking1; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx new file mode 100644 index 00000000..1b506e5f --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx @@ -0,0 +1,321 @@ +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 useExamEditorStore from "@/stores/examEditor"; +import { useEffect, useState } from "react"; +import useSectionEdit from "../../Hooks/useSectionEdit"; +import Header from "../../Shared/Header"; +import { Tooltip } from "react-tooltip"; +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); + + const { generating, genResult } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + 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: () => { + setLocal(exercise); + }, + onMode: () => { }, + }); + + + useEffect(() => { + if (genResult && generating === "context") { + setEditing(true); + setLocal({ + ...local, + title: genResult[0].topic, + text: genResult[0].question, + prompts: genResult[0].prompts + }); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // 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, + prompts: [...prev.prompts, ""] + })); + }; + + const removePrompt = (index: number) => { + setLocal(prev => ({ + ...prev, + prompts: prev.prompts.filter((_, i) => i !== index) + })); + }; + + const updatePrompt = (index: number, text: string) => { + setLocal(prev => { + const newPrompts = [...prev.prompts]; + newPrompts[index] = text; + return { ...prev, prompts: newPrompts }; + }); + }; + + const isUnedited = local.text === "" || + (local.title === undefined || local.title === "") || + local.prompts.length === 0; + + const tooltipContent = ` +
+

+ Prompts are guiding points that help candidates structure their talk. They typically include aspects like: +

    +
  • Describing what/who/where
  • +
  • Explaining why
  • +
  • Sharing feelings or preferences
  • +
+

+
+ `; + + return ( + <> +
+
+
+ {generating && generating === "context" ? ( + + ) : ( + <> + {editing ? ( + <> + + +
+

Title

+ setLocal(prev => ({ ...prev, title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the topic" + /> +
+
+
+ + +
+

Question

+ setLocal(prev => ({ ...prev, text: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the main question" + /> +
+
+
+ + +
+

Prompts

+ + + + +
+ +
+ {local.prompts.length === 0 ? ( +
+

No prompts added yet

+
+ ) : ( + local.prompts.map((prompt, index) => ( + + +
+
+

Prompt {index + 1}

+ +
+ updatePrompt(index, text)} + className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white" + placeholder={`Enter prompt ${index + 1}`} + /> +
+
+
+ )) + )} +
+ +
+ +
+
+
+ + ) : isUnedited ? ( +

+ Generate or edit the script! +

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

Video

+
+
+ +
+
+
+
+ } + {generating && generating === "media" && + + } + + +
+
+ +

Title

+
+
+

{local.title || 'Untitled'}

+
+
+
+
+ + +
+
+ +

Question

+
+
+

{local.text || 'No question provided'}

+
+
+
+
+ {local.prompts && local.prompts.length > 0 && ( + + +
+
+ +

Prompts

+
+
+
+ {local.prompts.map((prompt, index) => ( +
+

{prompt}

+
+ ))} +
+
+
+
+
+ )} +
+ )} + + )} + + ); +} + +export default Speaking2; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Speaking/index.tsx b/src/components/ExamEditor/Exercises/Speaking/index.tsx index 0dea9f87..eed5a2a7 100644 --- a/src/components/ExamEditor/Exercises/Speaking/index.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/index.tsx @@ -7,6 +7,9 @@ import Header from "../../Shared/Header"; import GenLoader from "../Shared/GenLoader"; import { Card, CardContent } from "@/components/ui/card"; import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import Speaking2 from "./Speaking2"; +import InteractiveSpeaking from "./InteractiveSpeaking"; +import Speaking1 from "./Speaking1"; interface Props { sectionId: number; @@ -14,176 +17,24 @@ interface Props { } const Speaking: React.FC = ({ sectionId, exercise }) => { - const { currentModule, dispatch } = useExamEditorStore(); - const { generating, genResult } = useExamEditorStore( + const { currentModule } = useExamEditorStore(); + const { state } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); - const { edit } = useExamEditorStore((store) => store.modules[currentModule]); - - const [local, setLocal] = useState(exercise); - const [loading, setLoading] = useState(generating === "context"); - const [questions, setQuestions] = useState(() => { - if (sectionId === 1) { - if ((exercise as SpeakingExercise).prompts.length > 0) { - return (exercise as SpeakingExercise).prompts; - } - return Array(5).fill(""); - } else if (sectionId === 2) { - if ((exercise as SpeakingExercise).text && (exercise as SpeakingExercise).prompts.length > 0) { - return (exercise as SpeakingExercise).prompts; - } - return Array(3).fill(""); - } else { - if ((exercise as InteractiveSpeakingExercise).prompts.length > 0) { - return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text); - } - return Array(5).fill(""); - } - }); - - const updateModule = useCallback((updates: Partial) => { - dispatch({ type: 'UPDATE_MODULE', payload: { updates } }); - }, [dispatch]); - - const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ - sectionId, - mode: "edit", - onSave: () => { - let newExercise; - if (sectionId === 1) { - newExercise = { - ...local, - prompts: questions - } as SpeakingExercise; - } else if (sectionId === 2) { - newExercise = { - ...local, - text: questions[0], - prompts: questions.slice(1), - } as SpeakingExercise; - } else { - newExercise = { - ...local, - prompts: questions.map(text => ({ - text, - video_url: (local as InteractiveSpeakingExercise).prompts?.[0]?.video_url || "" - })) - } as InteractiveSpeakingExercise; - } - setEditing(false); - dispatch({ - type: "UPDATE_SECTION_STATE", - payload: { - sectionId: sectionId, - update: newExercise - } - }); - }, - onDiscard: () => { - setLocal(exercise); - }, - onMode: () => { - setLocal(exercise); - if (sectionId === 1) { - setQuestions(Array(5).fill("")); - } else if (sectionId === 2) { - setQuestions(Array(3).fill("")); - } else { - setQuestions(Array(5).fill("")); - } - }, - }); - - useEffect(() => { - const isLoading = generating === "context"; - setLoading(isLoading); - - if (isLoading) { - updateModule({ edit: Array.from(new Set([...edit, sectionId])) }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [generating, sectionId]); - - useEffect(() => { - if (genResult && generating === "context") { - setEditing(true); - if (sectionId === 1) { - setQuestions(genResult[0].questions); - } else if (sectionId === 2) { - setQuestions([genResult[0].question, ...genResult[0].prompts]); - } else { - setQuestions(genResult[0].questions); - } - - dispatch({ - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "genResult", - value: undefined - } - }); - } - }, [genResult, generating, dispatch, sectionId, setEditing, currentModule]); - - const handleQuestionChange = (index: number, value: string) => { - setQuestions(prev => { - const newQuestions = [...prev]; - newQuestions[index] = value; - return newQuestions; - }); - }; - - const getQuestionLabel = (index: number) => { - if (sectionId === 2 && index === 0) { - return "Main Question"; - } else if (sectionId === 2) { - return `Prompt ${index}`; - } else { - return `Question ${index + 1}`; - } - }; return ( <> -
-
-
- {loading ? ( - - ) : ( -
- - -
-
- {questions.map((question: string, index: number) => ( -
-

{getQuestionLabel(index)}

- handleQuestionChange(index, text)} - className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" - placeholder={`Enter ${getQuestionLabel(index).toLowerCase()}`} - /> -
- ))} -
-
-
-
+
+
+
+ {sectionId === 1 && } + {sectionId === 2 && } + {sectionId === 3 && } +
- )} +
); }; -export default Speaking; +export default Speaking; \ No newline at end of file diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx index 032c89a4..852128d5 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx @@ -6,6 +6,9 @@ import useSectionEdit from "../../Hooks/useSectionEdit"; import ScriptRender from "../../Exercises/Script"; import { Card, CardContent } from "@/components/ui/card"; import Dropdown from "@/components/Dropdown"; +import AudioPlayer from "@/components/Low/AudioPlayer"; +import { MdHeadphones } from "react-icons/md"; +import clsx from "clsx"; const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => { @@ -41,7 +44,7 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => { }, [genResult, dispatch, sectionId, setEditing, currentModule]); const renderContent = (editing: boolean) => { - if (scriptLocal === undefined) { + if (scriptLocal === undefined && !editing) { return ( @@ -50,20 +53,38 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => { ); } - return ( - - - + return ( + <> + + {listeningPart.audio?.source && ( + + )} + + + {(sectionId === 1 || sectionId === 3) ? "Conversation" : "Monologue"} +
+ } + > + + + ); }; 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/SectionRenderer/SectionExercises/reading.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx index 7b31d770..7a36757b 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx @@ -4,6 +4,7 @@ import ExerciseLabel from '../../Shared/ExerciseLabel'; import MatchSentences from '../../Exercises/MatchSentences'; import TrueFalse from '../../Exercises/TrueFalse'; import FillBlanksLetters from '../../Exercises/Blanks/Letters'; +import MultipleChoice from '../../Exercises/MultipleChoice'; const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): ExerciseItem[] => { @@ -87,6 +88,24 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer ), content: }; + case "multipleChoice": + firstWordId = exercise.questions[0].id; + lastWordId = exercise.questions[exercise.questions.length - 1].id; + return { + id: index.toString(), + sectionId, + label: ( + + "{previewLabel(exercise.prompt)}..." + + } + /> + ), + content: + }; } }).filter(isExerciseItem); diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts b/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts index 0a06164b..4a29c13b 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts @@ -1,4 +1,4 @@ -import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam"; +import { FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam"; export default interface ExerciseItem { id: string; @@ -7,7 +7,7 @@ export default interface ExerciseItem { content: React.ReactNode; } -export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise; +export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise | MultipleChoiceExercise; export function isExerciseItem(item: unknown): item is ExerciseItem { return item !== undefined && diff --git a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx index dfa664b4..9a2f9583 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx @@ -10,17 +10,24 @@ interface Props { sectionId: number; genType: Generating; generateFnc: (sectionId: number) => void + className?: string; } -const GenerateBtn: React.FC = ({module, sectionId, genType, generateFnc}) => { - const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!; +const GenerateBtn: React.FC = ({module, sectionId, genType, generateFnc, className}) => { + 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/ExamEditor/Shared/ImportExam/WordUploader.tsx b/src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx index 23ed7d01..c52a4620 100644 --- a/src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx +++ b/src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx @@ -6,7 +6,7 @@ import { capitalize } from 'lodash'; import { Module } from '@/interfaces'; import { toast } from 'react-toastify'; import useExamEditorStore from '@/stores/examEditor'; -import { ReadingPart } from '@/interfaces/exam'; +import { LevelPart, ReadingPart } from '@/interfaces/exam'; import { defaultSectionSettings } from '@/stores/examEditor/defaults'; const WordUploader: React.FC<{ module: Module }> = ({ module }) => { @@ -72,7 +72,7 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => { setShowUploaders(false); switch (currentModule) { - case 'reading': + case 'reading': { const newSectionsStates = data.parts.map( (part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part) ); @@ -88,9 +88,31 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => { } }); break; + } + case 'level': { + const newSectionsStates = data.parts.map( + (part: LevelPart, index: number) => defaultSectionSettings(module, index + 1, part) + ); + dispatch({ + type: "UPDATE_MODULE", payload: { + updates: { + sections: newSectionsStates, + minTimer: data.minTimer, + importModule: false, + importing: false, + sectionLabels: Array.from({ length: newSectionsStates.length }, (_, index) => ({ + id: index + 1, + label: `Part ${index + 1}` + })) + }, + module + } + }); + break; + } } } catch (error) { - toast.error(`An unknown error has occured while import ${module} exam!`); + toast.error(`Make sure you've imported a valid word document (.docx)!`); } finally { dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: false }, module } }) } diff --git a/src/components/ExamEditor/Shared/Passage.tsx b/src/components/ExamEditor/Shared/Passage.tsx index 5efc7ccb..7e63a197 100644 --- a/src/components/ExamEditor/Shared/Passage.tsx +++ b/src/components/ExamEditor/Shared/Passage.tsx @@ -1,40 +1,49 @@ +import { useState } from "react"; import Dropdown from "@/components/Dropdown"; import clsx from "clsx"; interface Props { - title: string; - content: string; - open: boolean; - setIsOpen: React.Dispatch>; + title: string; + content: string; + open?: boolean; + setIsOpen?: React.Dispatch>; } -const Passage: React.FC = ({ title, content, open, setIsOpen}) => { - const paragraphs = content.split('\n\n'); +const Passage: React.FC = ({ title, content, open: externalOpen, setIsOpen: externalSetIsOpen }) => { + const [internalOpen, setInternalOpen] = useState(false); - return ( - +
+ {paragraphs.map((paragraph, index) => ( +

-

- {paragraphs.map((paragraph, index) => ( -

- {paragraph.trim()} -

- ))} -
- - ); + > + {paragraph.trim()} +

+ ))} +
+
+ ); } export default Passage; diff --git a/src/components/ExamEditor/index.tsx b/src/components/ExamEditor/index.tsx index aa2e0ca8..d72b4667 100644 --- a/src/components/ExamEditor/index.tsx +++ b/src/components/ExamEditor/index.tsx @@ -153,7 +153,7 @@ const ExamEditor: React.FC = () => { ) : (
- setNumberOfParts(parseInt(v))} value={numberOfParts} /> + setNumberOfParts(parseInt(v))} value={numberOfParts} />
)}
@@ -171,7 +171,7 @@ const ExamEditor: React.FC = () => { name="label" onChange={(text) => updateModule({ examLabel: text })} roundness="xl" - defaultValue={examLabel} + value={examLabel} required />
diff --git a/src/components/Exercises/InteractiveSpeaking.tsx b/src/components/Exercises/InteractiveSpeaking.tsx index 08a1eef7..7653fe13 100644 --- a/src/components/Exercises/InteractiveSpeaking.tsx +++ b/src/components/Exercises/InteractiveSpeaking.tsx @@ -24,6 +24,7 @@ export default function InteractiveSpeaking({ userSolutions, onNext, onBack, + preview = false }: InteractiveSpeakingExercise & CommonProps) { const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); @@ -298,9 +299,15 @@ export default function InteractiveSpeaking({ - + ) : ( + + )}
diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 12e942d0..b96714c9 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, }); }; @@ -189,7 +189,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 +307,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 7ac86d13..baef3ca1 100644 --- a/src/exams/Level/index.tsx +++ b/src/exams/Level/index.tsx @@ -77,6 +77,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]); @@ -502,7 +511,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview = { !(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) && - + } {(showPartDivider || startNow) ? 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 0a5e3df9..cd7001e6 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -196,7 +196,6 @@ export interface SpeakingExercise extends Section { evaluation?: SpeakingEvaluation; }[]; topic?: string; - script?: Script; } export interface InteractiveSpeakingExercise extends Section { @@ -216,7 +215,6 @@ export interface InteractiveSpeakingExercise extends Section { first_topic?: string; second_topic?: string; variant?: "initial" | "final"; - script?: Script; } export interface FillBlanksMCOption { @@ -307,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/avatars.ts b/src/pages/api/exam/avatars.ts new file mode 100644 index 00000000..1a87a7fb --- /dev/null +++ b/src/pages/api/exam/avatars.ts @@ -0,0 +1,21 @@ +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 result = await axios.get(`${process.env.BACKEND_URL}/speaking/avatars`, { + headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` }, + }); + res.status(200).json(result.data); +} 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 new file mode 100644 index 00000000..ac3e95a3 --- /dev/null +++ b/src/pages/api/exam/media/[...module].ts @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import axios from "axios"; +import queryToURLSearchParams from "@/utils/query.to.url.params"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return post(req, res); + + return res.status(404).json({ ok: false }); +} + +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("/"); + + 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.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/api/storage/index.ts b/src/pages/api/storage/index.ts new file mode 100644 index 00000000..2fbd1fa6 --- /dev/null +++ b/src/pages/api/storage/index.ts @@ -0,0 +1,68 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'; +import { IncomingForm, Files, Fields } from 'formidable'; +import { promises as fs } from 'fs'; +import { withIronSessionApiRoute } from 'iron-session/next'; +import { storage } from '@/firebase'; +import { sessionOptions } from '@/lib/session'; +import { v4 } from 'uuid'; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default withIronSessionApiRoute(handler, sessionOptions); + +export async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ error: 'Not authorized' }); + return; + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Not allowed' }); + } + + const directory = (req.query.directory as string) || "uploads"; + + try { + const form = new IncomingForm({ + keepExtensions: true, + multiples: true, + }); + + const [fields, files]: [Fields, Files] = await new Promise((resolve, reject) => { + form.parse(req, (err, fields, files) => { + if (err) reject(err); + resolve([fields, files]); + }); + }); + + const fileArray = files.file; + if (!fileArray) { + return res.status(400).json({ error: 'No files provided' }); + } + + const filesToProcess = Array.isArray(fileArray) ? fileArray : [fileArray]; + + const uploadPromises = filesToProcess.map(async (file) => { + const split = file.originalFilename?.split('.') || ["bin"]; + const extension = split[split.length - 1]; + + const buffer = await fs.readFile(file.filepath); + const storageRef = ref(storage, `${directory}/${v4()}.${extension}`); + await uploadBytes(storageRef, buffer); + const downloadURL = await getDownloadURL(storageRef); + await fs.unlink(file.filepath); + return downloadURL; + }); + + const urls = await Promise.all(uploadPromises); + res.status(200).json({ urls }); + } catch (error) { + console.error('Upload error:', error); + res.status(500).json({ error: 'Upload failed' }); + } +} \ No newline at end of file diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index e6d2f988..e4bba0a8 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -20,10 +20,11 @@ import { mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; import { Module } from "@/interfaces"; import { getExam, getExams } from "@/utils/exams.be"; -import { Exam } from "@/interfaces/exam"; +import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam"; import { useEffect } from "react"; import { getEntitiesWithRoles } from "@/utils/entities.be"; import { isAdmin } from "@/utils/users"; +import axios from "axios"; type Permission = { [key in Module]: boolean } @@ -60,12 +61,68 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, query }) }, sessionOptions); export default function Generation({ user, exam, permissions }: { user: User; exam?: Exam, permissions: Permission }) { - const { title, currentModule, dispatch } = useExamEditorStore(); + const { title, currentModule, modules, dispatch } = useExamEditorStore(); const updateRoot = (updates: Partial) => { dispatch({ type: 'UPDATE_ROOT', payload: { updates } }); }; + useEffect(() => { + const fetchAvatars = async () => { + const response = await axios.get("/api/exam/avatars"); + updateRoot({ speakingAvatars: response.data }); + }; + + fetchAvatars(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // media cleanup on unmount + useEffect(() => { + return () => { + const state = modules; + state.listening.sections.forEach(section => { + const listeningPart = section.state as ListeningPart; + if (listeningPart.audio?.source) { + URL.revokeObjectURL(listeningPart.audio.source); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: section.sectionId, module: "listening", field: "state", value: { ...listeningPart, audio: undefined } + } + }) + } + }); + + state.speaking.sections.forEach(section => { + const sectionState = section.state as Exercise; + if (sectionState.type === 'speaking') { + const speakingExercise = sectionState as SpeakingExercise; + URL.revokeObjectURL(speakingExercise.video_url); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: section.sectionId, module: "listening", field: "state", value: { ...speakingExercise, video_url: undefined } + } + }) + } + if (sectionState.type === 'interactiveSpeaking') { + const interactiveSpeaking = sectionState as InteractiveSpeakingExercise; + interactiveSpeaking.prompts.forEach(prompt => { + URL.revokeObjectURL(prompt.video_url); + }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: section.sectionId, module: "listening", field: "state", value: { + ...interactiveSpeaking, prompts: interactiveSpeaking.prompts.map((p) => ({ ...p, video_url: undefined })) + } + } + }) + } + }); + dispatch({type: 'FULL_RESET'}); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (exam) { } }, [exam]) @@ -84,7 +141,7 @@ export default function Generation({ user, exam, permissions }: { user: User; ex {user && ( -

Exam Generation

+

Exam Editor

{ case 'listening': return { ...baseSettings, - isAudioContextOpen: false + isAudioContextOpen: false, + isAudioGenerationOpen: false, + } + case 'speaking': + return { + ...baseSettings, + isGenerateAudioOpen: false } default: return baseSettings; diff --git a/src/stores/examEditor/index.ts b/src/stores/examEditor/index.ts index 845b7a40..7db76092 100644 --- a/src/stores/examEditor/index.ts +++ b/src/stores/examEditor/index.ts @@ -11,6 +11,7 @@ const useExamEditorStore = create< title: "", globalEdit: [], currentModule: "reading", + speakingAvatars: [], modules: { reading: defaultModuleSettings("reading", 60), writing: defaultModuleSettings("writing", 60), 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 6ddc3e87..77494e66 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -22,6 +22,7 @@ export interface SectionSettings { export interface SpeakingSectionSettings extends SectionSettings { secondTopic?: string; + isGenerateAudioOpen: boolean; } export interface ReadingSectionSettings extends SectionSettings { @@ -30,6 +31,14 @@ export interface ReadingSectionSettings extends SectionSettings { export interface ListeningSectionSettings extends SectionSettings { isAudioContextOpen: boolean; + isAudioGenerationOpen: boolean; +} + +export interface LevelSectionSettings extends SectionSettings { + readingDropdownOpen: boolean; + writingDropdownOpen: boolean; + speakingDropdownOpen: boolean; + listeningDropdownOpen: boolean; } export type Generating = "context" | "exercises" | "media" | undefined; @@ -61,9 +70,15 @@ export interface ModuleState { edit: number[]; } +export interface Avatar { + name: string; + gender: string; +} + export default interface ExamEditorStore { title: string; currentModule: Module; + speakingAvatars: Avatar[]; modules: { [K in Module]: ModuleState };