Reverted Level to only utas placement test exercises, Speaking, bug fixes, placeholder
This commit is contained in:
@@ -42,6 +42,8 @@ export function generate(
|
||||
|
||||
const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
console.log(config.body);
|
||||
|
||||
const request = config.method === 'POST'
|
||||
? axios.post(url, config.body)
|
||||
: axios.get(url);
|
||||
|
||||
@@ -14,7 +14,12 @@ interface Props {
|
||||
}
|
||||
|
||||
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => {
|
||||
const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!;
|
||||
const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId));
|
||||
if (section === undefined) return;
|
||||
|
||||
const {generating} = section;
|
||||
|
||||
|
||||
const loading = generating && generating === genType;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { Avatar } from "../speaking";
|
||||
import axios from "axios";
|
||||
|
||||
interface VideoResponse {
|
||||
status: 'STARTED' | 'ERROR' | 'COMPLETED' | 'IN_PROGRESS';
|
||||
result: string;
|
||||
}
|
||||
|
||||
interface VideoGeneration {
|
||||
index: number;
|
||||
text: string;
|
||||
videoId?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export async function generateVideos(section: InteractiveSpeakingExercise | SpeakingExercise, focusedSection: number, selectedAvatar: Avatar | null, speakingAvatars: Avatar[]) {
|
||||
const abortController = new AbortController();
|
||||
let activePollingIds: string[] = [];
|
||||
|
||||
const avatarToUse = selectedAvatar || speakingAvatars[Math.floor(Math.random() * speakingAvatars.length)];
|
||||
|
||||
const pollVideoGeneration = async (videoId: string): Promise<string> => {
|
||||
while (true) {
|
||||
try {
|
||||
const { data } = await axios.get<VideoResponse>(`api/exam/media/poll?videoId=${videoId}`, {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (data.status === 'ERROR') {
|
||||
abortController.abort();
|
||||
throw new Error('Video generation failed');
|
||||
}
|
||||
|
||||
if (data.status === 'COMPLETED') {
|
||||
const videoResponse = await axios.get(data.result, {
|
||||
responseType: 'blob',
|
||||
signal: abortController.signal
|
||||
});
|
||||
const videoUrl = URL.createObjectURL(
|
||||
new Blob([videoResponse.data], { type: 'video/mp4' })
|
||||
);
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10000)); // 10 secs
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError' || axios.isCancel(error)) {
|
||||
throw new Error('Operation aborted');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateSingleVideo = async (text: string, index: number): Promise<VideoGeneration> => {
|
||||
try {
|
||||
const { data } = await axios.post<VideoResponse>('/api/exam/media/speaking',
|
||||
{ text, avatar: avatarToUse.name },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: abortController.signal
|
||||
}
|
||||
);
|
||||
|
||||
if (data.status === 'ERROR') {
|
||||
abortController.abort();
|
||||
throw new Error('Initial video generation failed');
|
||||
}
|
||||
|
||||
activePollingIds.push(data.result);
|
||||
const videoUrl = await pollVideoGeneration(data.result);
|
||||
return { index, text, videoId: data.result, url: videoUrl };
|
||||
} catch (error) {
|
||||
abortController.abort();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
let videosToGenerate: { text: string; index: number }[] = [];
|
||||
switch (focusedSection) {
|
||||
case 1: {
|
||||
const interactiveSection = section as InteractiveSpeakingExercise;
|
||||
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
|
||||
text: index === 0 ? prompt.text.replace("{avatar}", avatarToUse.name) : prompt.text,
|
||||
index
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
const speakingSection = section as SpeakingExercise;
|
||||
videosToGenerate = [{ text: `${speakingSection.text}. You have 1 minute to take notes.`, index: 0 }];
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
const interactiveSection = section as InteractiveSpeakingExercise;
|
||||
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
|
||||
text: prompt.text,
|
||||
index
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all videos concurrently
|
||||
const results = await Promise.all(
|
||||
videosToGenerate.map(({ text, index }) => generateSingleVideo(text, index))
|
||||
);
|
||||
|
||||
// by order which they came in
|
||||
return results.sort((a, b) => a.index - b.index);
|
||||
|
||||
} catch (error) {
|
||||
// Clean up any ongoing requests
|
||||
abortController.abort();
|
||||
// Clean up any created URLs
|
||||
activePollingIds.forEach(id => {
|
||||
if (id) URL.revokeObjectURL(id);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
});
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}>
|
||||
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
|
||||
|
||||
@@ -7,15 +7,33 @@ import ExercisePicker from "../Shared/ExercisePicker";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import { SectionSettings } from "@/stores/examEditor/types";
|
||||
import { toast } from "react-toastify";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
|
||||
|
||||
const LevelSettings: React.FC = () => {
|
||||
|
||||
const {currentModule } = useExamEditorStore();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const {currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
||||
@@ -23,45 +41,80 @@ const LevelSettings: React.FC = () => {
|
||||
focusedSection
|
||||
);
|
||||
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as LevelPart;
|
||||
const section = sections.find((section) => section.sectionId == focusedSection);
|
||||
if (section === undefined) return;
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Multiple Choice",
|
||||
value: "Not available."
|
||||
},
|
||||
{
|
||||
label: "Preset: Multiple Choice - Blank Space",
|
||||
value: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".",
|
||||
},
|
||||
{
|
||||
label: "Preset: Multiple Choice - Underlined",
|
||||
value: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\"."
|
||||
},
|
||||
{
|
||||
label: "Preset: Blank Space",
|
||||
value: "Not available."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage",
|
||||
value: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\"."
|
||||
},
|
||||
{
|
||||
label: "Preset: Multiple Choice - Fill Blanks",
|
||||
value: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option that you believe best fits the context."
|
||||
const currentSection = section.state as LevelPart;
|
||||
|
||||
const canPreview = currentSection.exercises.length > 0;
|
||||
|
||||
const submitLevel = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
];
|
||||
const exam: LevelExam = {
|
||||
parts: sections.map((s) => {
|
||||
const part = s.state as LevelPart;
|
||||
return {
|
||||
...part,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
axios.post(`/api/exam/level`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
||||
})
|
||||
}
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as LevelPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
} as LevelExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
openDetachedTab("popout?type=Exam&module=level", router)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Part ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="level"
|
||||
introPresets={defaultPresets}
|
||||
preview={()=>{}}
|
||||
canPreview={false}
|
||||
canSubmit={false}
|
||||
submitModule={()=> {}}
|
||||
introPresets={[]}
|
||||
preview={preview}
|
||||
canPreview={canPreview}
|
||||
canSubmit={canPreview}
|
||||
submitModule={submitLevel}
|
||||
>
|
||||
<div>
|
||||
<Dropdown title="Add Exercises" className={
|
||||
|
||||
@@ -33,6 +33,7 @@ const ListeningSettings: React.FC = () => {
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
|
||||
@@ -40,17 +41,25 @@ const ListeningSettings: React.FC = () => {
|
||||
focusedSection
|
||||
);
|
||||
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as ListeningPart;
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ListeningPart;
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Writing Task 1",
|
||||
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||
},
|
||||
{
|
||||
label: "Preset: Writing Task 2",
|
||||
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||
}
|
||||
label: "Preset: Listening Section 1",
|
||||
value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions."
|
||||
},
|
||||
{
|
||||
label: "Preset: Listening Section 2",
|
||||
value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements."
|
||||
},
|
||||
{
|
||||
label: "Preset: Listening Section 3",
|
||||
value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements."
|
||||
},
|
||||
{
|
||||
label: "Preset: Listening Section 4",
|
||||
value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject."
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -123,8 +132,8 @@ const ListeningSettings: React.FC = () => {
|
||||
...exercise.audio,
|
||||
source: index !== -1 ? urls[index] : exercise.audio.source
|
||||
} : undefined,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
@@ -159,8 +168,8 @@ const ListeningSettings: React.FC = () => {
|
||||
const exercise = s.state as ListeningPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
@@ -174,6 +183,7 @@ const ListeningSettings: React.FC = () => {
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=listening", router)
|
||||
}
|
||||
|
||||
@@ -226,7 +236,7 @@ const ListeningSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSection.script, dispatch]);
|
||||
}, [currentSection?.script, dispatch]);
|
||||
|
||||
const canPreview = sections.some(
|
||||
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
||||
@@ -283,7 +293,7 @@ const ListeningSettings: React.FC = () => {
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
disabled={currentSection.script === undefined && currentSection.audio === undefined}
|
||||
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="listening"
|
||||
@@ -297,7 +307,7 @@ const ListeningSettings: React.FC = () => {
|
||||
module={currentModule}
|
||||
open={localSettings.isAudioGenerationOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
|
||||
disabled={currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
|
||||
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
|
||||
center
|
||||
>
|
||||
<GenerateBtn
|
||||
|
||||
@@ -25,6 +25,7 @@ const ReadingSettings: React.FC = () => {
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
@@ -41,17 +42,22 @@ const ReadingSettings: React.FC = () => {
|
||||
focusedSection
|
||||
);
|
||||
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as ReadingPart;
|
||||
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
|
||||
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Writing Task 1",
|
||||
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||
},
|
||||
{
|
||||
label: "Preset: Writing Task 2",
|
||||
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||
}
|
||||
label: "Preset: Reading Passage 1",
|
||||
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 2",
|
||||
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
|
||||
},
|
||||
{
|
||||
label: "Preset: Reading Passage 3",
|
||||
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -125,8 +131,8 @@ const ReadingSettings: React.FC = () => {
|
||||
const exercise = s.state as ReadingPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
@@ -140,6 +146,7 @@ const ReadingSettings: React.FC = () => {
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router)
|
||||
}
|
||||
|
||||
@@ -188,13 +195,13 @@ const ReadingSettings: React.FC = () => {
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||
disabled={currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
|
||||
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="reading"
|
||||
sectionId={focusedSection}
|
||||
difficulty={difficulty}
|
||||
extraArgs={{ text: currentSection.text.content }}
|
||||
extraArgs={{ text: currentSection === undefined ? "" : currentSection.text.content }}
|
||||
/>
|
||||
</Dropdown>
|
||||
</SettingsEditor>
|
||||
|
||||
@@ -2,34 +2,65 @@ import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
|
||||
import Option from "@/interfaces/option";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { generate } from "./Shared/Generate";
|
||||
import SettingsEditor from ".";
|
||||
import Dropdown from "./Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import GenerateBtn from "./Shared/GenerateBtn";
|
||||
import clsx from "clsx";
|
||||
import { FaFemale, FaMale, FaChevronDown } from "react-icons/fa";
|
||||
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { toast } from "react-toastify";
|
||||
import { generateVideos } from "./Shared/generateVideos";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { useRouter } from "next/router";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
|
||||
export interface Avatar {
|
||||
name: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
const SpeakingSettings: React.FC = () => {
|
||||
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { title, currentModule, speakingAvatars, dispatch } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
|
||||
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection,
|
||||
);
|
||||
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Writing Task 1",
|
||||
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
|
||||
},
|
||||
{
|
||||
label: "Preset: Writing Task 2",
|
||||
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
|
||||
}
|
||||
label: "Preset: Speaking Part 1",
|
||||
value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
|
||||
},
|
||||
{
|
||||
label: "Preset: Speaking Part 2",
|
||||
value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
|
||||
},
|
||||
{
|
||||
label: "Preset: Speaking Part 3",
|
||||
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
|
||||
}
|
||||
];
|
||||
|
||||
const generateScript = useCallback((sectionId: number) => {
|
||||
@@ -39,7 +70,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
second_topic?: string;
|
||||
topic?: string;
|
||||
} = { difficulty };
|
||||
|
||||
|
||||
if (sectionId === 1) {
|
||||
if (localSettings.topic) {
|
||||
queryParams['first_topic'] = localSettings.topic;
|
||||
@@ -86,7 +117,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings, difficulty]);
|
||||
|
||||
const onTopicChange = useCallback((topic: string) => {
|
||||
@@ -97,16 +128,256 @@ const SpeakingSettings: React.FC = () => {
|
||||
updateLocalAndScheduleGlobal({ secondTopic: topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const canPreviewOrSubmit = (() => {
|
||||
return sections.every((s) => {
|
||||
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
switch (section.type) {
|
||||
case 'speaking':
|
||||
return section.title !== '' &&
|
||||
section.text !== '' &&
|
||||
section.video_url !== '' &&
|
||||
section.prompts.every(prompt => prompt !== '');
|
||||
|
||||
case 'interactiveSpeaking':
|
||||
if ('first_title' in section && 'second_title' in section) {
|
||||
return section.first_title !== '' &&
|
||||
section.second_title !== '' &&
|
||||
section.prompts.every(prompt => prompt.video_url !== '') &&
|
||||
section.prompts.length > 2;
|
||||
}
|
||||
return section.title !== '' &&
|
||||
section.prompts.every(prompt => prompt.video_url !== '');
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
const canGenerate = section && (() => {
|
||||
switch (focusedSection) {
|
||||
case 1: {
|
||||
const currentSection = section as InteractiveSpeakingExercise;
|
||||
return currentSection.first_title !== "" &&
|
||||
currentSection.second_title !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2;
|
||||
}
|
||||
case 2: {
|
||||
const currentSection = section as SpeakingExercise;
|
||||
return currentSection.title !== "" &&
|
||||
currentSection.text !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt !== "");
|
||||
}
|
||||
case 3: {
|
||||
const currentSection = section as InteractiveSpeakingExercise;
|
||||
return currentSection.title !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt.text !== "");
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const generateVideoCallback = useCallback((sectionId: number) => {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "media" } })
|
||||
generateVideos(
|
||||
section as InteractiveSpeakingExercise | SpeakingExercise,
|
||||
sectionId,
|
||||
selectedAvatar,
|
||||
speakingAvatars
|
||||
).then((results) => {
|
||||
switch (sectionId) {
|
||||
case 1:
|
||||
case 3: {
|
||||
const interactiveSection = section as InteractiveSpeakingExercise;
|
||||
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
|
||||
...prompt,
|
||||
video_url: results[index].url || ''
|
||||
}));
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ prompts: updatedPrompts }] } })
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
if (results[0]?.url) {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ video_url: results[0].url }] } })
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
toast.error("Failed to generate the video, try again later!")
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAvatar, section]);
|
||||
|
||||
|
||||
const submitSpeaking = async () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const urlMap = new Map<string, string>();
|
||||
|
||||
const sectionsWithVideos = sections.filter(s => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
if (exercise.type === "speaking") {
|
||||
return exercise.video_url !== "";
|
||||
}
|
||||
if (exercise.type === "interactiveSpeaking") {
|
||||
return exercise.prompts?.some(prompt => prompt.video_url !== "");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (sectionsWithVideos.length === 0) {
|
||||
toast.error('No video sections found in the exam! Please record or import videos.');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sectionsWithVideos.map(async (section) => {
|
||||
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
const response = await fetch(exercise.video_url);
|
||||
const blob = await response.blob();
|
||||
formData.append('file', blob, 'video.mp4');
|
||||
urlMap.set(`${section.sectionId}`, exercise.video_url);
|
||||
} else {
|
||||
await Promise.all(
|
||||
exercise.prompts.map(async (prompt, promptIndex) => {
|
||||
if (prompt.video_url) {
|
||||
const response = await fetch(prompt.video_url);
|
||||
const blob = await response.blob();
|
||||
formData.append('file', blob, 'video.mp4');
|
||||
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const response = await axios.post('/api/storage', formData, {
|
||||
params: {
|
||||
directory: 'speaking_videos'
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
const { urls } = response.data;
|
||||
|
||||
const exam: SpeakingExam = {
|
||||
exercises: sections.map((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
const videoIndex = Array.from(urlMap.entries())
|
||||
.findIndex(([key]) => key === `${s.sectionId}`);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
} else {
|
||||
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
|
||||
const videoIndex = Array.from(urlMap.entries())
|
||||
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
|
||||
|
||||
return {
|
||||
...prompt,
|
||||
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
prompts: updatedPrompts,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}
|
||||
}),
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/speaking', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
Array.from(urlMap.values()).forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
exercises: sections
|
||||
.filter((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
return exercise.video_url !== "";
|
||||
}
|
||||
|
||||
if (exercise.type === "interactiveSpeaking") {
|
||||
return exercise.prompts?.every(prompt => prompt.video_url !== "");
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
return {
|
||||
...exercise,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
} as SpeakingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=speaking", router)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Speaking ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="speaking"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={() => { }}
|
||||
canPreview={false}
|
||||
canSubmit={false}
|
||||
submitModule={()=> {}}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitSpeaking}
|
||||
>
|
||||
<Dropdown
|
||||
title="Generate Script"
|
||||
@@ -115,7 +386,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
|
||||
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center" )}>
|
||||
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center")}>
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
|
||||
<Input
|
||||
@@ -155,19 +426,52 @@ const SpeakingSettings: React.FC = () => {
|
||||
<Dropdown
|
||||
title="Generate Video"
|
||||
module={currentModule}
|
||||
open={localSettings.isGenerateAudio}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudio: isOpen }, false)}
|
||||
open={localSettings.isGenerateAudioOpen}
|
||||
disabled={!canGenerate}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)}
|
||||
>
|
||||
<div className={clsx("gap-2 px-2 pb-4 flex flex-row items-center" )}>
|
||||
|
||||
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
|
||||
<GenerateBtn
|
||||
module={currentModule}
|
||||
genType="media"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateScript}
|
||||
/>
|
||||
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<select
|
||||
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "") {
|
||||
setSelectedAvatar(null);
|
||||
} else {
|
||||
const [name, gender] = e.target.value.split("-");
|
||||
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
|
||||
if (avatar) setSelectedAvatar(avatar);
|
||||
}
|
||||
}}
|
||||
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select an avatar</option>
|
||||
{speakingAvatars.map((avatar) => (
|
||||
<option
|
||||
key={`${avatar.name}-${avatar.gender}`}
|
||||
value={`${avatar.name}-${avatar.gender}`}
|
||||
>
|
||||
{avatar.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-2.5 top-2.5 pointer-events-none">
|
||||
{selectedAvatar && (
|
||||
selectedAvatar.gender === 'male' ? (
|
||||
<FaMale className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FaFemale className="w-5 h-5 text-pink-500" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GenerateBtn
|
||||
module={currentModule}
|
||||
genType="media"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateVideoCallback}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</SettingsEditor>
|
||||
|
||||
Reference in New Issue
Block a user