diff --git a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx new file mode 100644 index 00000000..83d948d6 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx @@ -0,0 +1,222 @@ +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"; + +interface Props { + sectionId: number; + exercise: InteractiveSpeakingExercise; +} + +const InteractiveSpeaking: 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); + 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; + + return ( + <> +
+
+
+ {generating ? ( + + ) : ( + <> + {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 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..33ffb90d --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx @@ -0,0 +1,280 @@ +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"; + +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 updateAvatarName = (avatarName: string) => { + setLocal(prev => { + const updatedPrompts = [...prev.prompts]; + updatedPrompts[0] = { + ...updatedPrompts[0], + text: updatedPrompts[0].text.replace("{avatar}", avatarName) + }; + return { ...prev, prompts: updatedPrompts }; + }); + }; + + const { 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; + + return ( + <> +
+
+
+ {generating ? ( + + ) : ( + <> + {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! +

+ ) : ( +
+ + +
+
+ +

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..1a5df28f --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx @@ -0,0 +1,280 @@ +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"; + +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 updateTopic = (topic: string) => { + setLocal(prev => ({ ...prev, topic: topic })); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { topic: topic } } }); + }; + + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + 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]); + + 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 ? ( + + ) : ( + <> + {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! +

+ ) : ( +
+ + +
+
+ +

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/SettingsEditor/listening.tsx b/src/components/ExamEditor/SettingsEditor/listening.tsx index 6be5d20e..884130e1 100644 --- a/src/components/ExamEditor/SettingsEditor/listening.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening.tsx @@ -19,7 +19,6 @@ import { toast } from "react-toastify"; const ListeningSettings: React.FC = () => { const router = useRouter(); - const [audioLoading, setAudioLoading] = useState(false); const { currentModule, title, dispatch } = useExamEditorStore(); const { focusedSection, @@ -187,7 +186,7 @@ const ListeningSettings: React.FC = () => { } try { - setAudioLoading(true); + dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: "media"}}); const response = await axios.post( '/api/exam/media/listening', body, @@ -223,7 +222,7 @@ const ListeningSettings: React.FC = () => { } catch (error: any) { toast.error('Failed to generate audio'); } finally { - setAudioLoading(false); + dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: undefined}}); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -303,7 +302,7 @@ const ListeningSettings: React.FC = () => { > { - const { currentModule, dispatch } = useExamEditorStore(); + const { currentModule } = useExamEditorStore(); const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule]) const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( @@ -33,7 +33,6 @@ const SpeakingSettings: React.FC = () => { ]; const generateScript = useCallback((sectionId: number) => { - const queryParams: { difficulty: string; first_topic?: string; @@ -57,7 +56,7 @@ const SpeakingSettings: React.FC = () => { generate( sectionId, currentModule, - "context", + "context", // <- not really context but exercises is reserved for reading, listening and level { method: 'GET', queryParams @@ -66,9 +65,9 @@ const SpeakingSettings: React.FC = () => { switch (sectionId) { case 1: return [{ - questions: data.questions, - firstTopic: data.first_topic, - secondTopic: data.second_topic + prompts: data.questions, + first_topic: data.first_topic, + second_topic: data.second_topic }]; case 2: return [{ @@ -79,8 +78,8 @@ const SpeakingSettings: React.FC = () => { }]; case 3: return [{ - topic: data.topic, - questions: data.questions + title: data.topic, + prompts: data.questions }]; default: return [data]; @@ -95,7 +94,7 @@ const SpeakingSettings: React.FC = () => { }, [updateLocalAndScheduleGlobal]); const onSecondTopicChange = useCallback((topic: string) => { - updateLocalAndScheduleGlobal({ }); + updateLocalAndScheduleGlobal({ secondTopic: topic }); }, [updateLocalAndScheduleGlobal]); return ( @@ -153,6 +152,24 @@ const SpeakingSettings: React.FC = () => {
+ updateLocalAndScheduleGlobal({ isGenerateAudio: isOpen }, false)} + > +
+ +
+ +
+
+
); }; diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 0a5e3df9..a79212ff 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 { 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/generation.tsx b/src/pages/generation.tsx index 3c1acfd4..b4d92559 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -20,7 +20,7 @@ import { redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; import { useEffect } from "react"; import { Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam"; -import { type } from "os"; +import axios from "axios"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) @@ -41,6 +41,17 @@ export default function Generation({ user }: { user: User; }) { dispatch({ type: 'UPDATE_ROOT', payload: { updates } }); }; + useEffect(() => { + const fetchAvatars = async () => { + const response = await axios.get("/api/exam/avatars"); + console.log(response.data); + updateRoot({ speakingAvatars: response.data }); + }; + + fetchAvatars(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // media cleanup on unmount useEffect(() => { return () => { @@ -49,9 +60,11 @@ export default function Generation({ user }: { user: User; }) { 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} - }}) + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: section.sectionId, module: "listening", field: "state", value: { ...listeningPart, audio: undefined } + } + }) } }); @@ -60,20 +73,24 @@ export default function Generation({ user }: { user: User; }) { 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} - }}) + 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: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: section.sectionId, module: "listening", field: "state", value: { + ...interactiveSpeaking, prompts: interactiveSpeaking.prompts.map((p) => ({ ...p, video_url: undefined })) + } } - }}) + }) } }); diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index fb12e09d..690e007a 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -29,6 +29,11 @@ const defaultSettings = (module: Module) => { isAudioContextOpen: false, isAudioGenerationOpen: false, } + case 'speaking': + return { + ...baseSettings, + isGenerateAudio: 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/types.ts b/src/stores/examEditor/types.ts index b0a171fb..a97a377d 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; + isGenerateAudio: boolean; } export interface ReadingSectionSettings extends SectionSettings { @@ -62,9 +63,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 };