482 lines
19 KiB
TypeScript
482 lines
19 KiB
TypeScript
import useExamEditorStore from "@/stores/examEditor";
|
|
import useSettingsState from "../Hooks/useSettingsState";
|
|
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
|
|
import Option from "@/interfaces/option";
|
|
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 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: 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) => {
|
|
const queryParams: {
|
|
difficulty: string;
|
|
first_topic?: string;
|
|
second_topic?: string;
|
|
topic?: string;
|
|
} = { difficulty };
|
|
|
|
if (sectionId === 1) {
|
|
if (localSettings.topic) {
|
|
queryParams['first_topic'] = localSettings.topic;
|
|
}
|
|
if (localSettings.secondTopic) {
|
|
queryParams['second_topic'] = localSettings.secondTopic;
|
|
}
|
|
} else {
|
|
if (localSettings.topic) {
|
|
queryParams['topic'] = localSettings.topic;
|
|
}
|
|
}
|
|
|
|
generate(
|
|
sectionId,
|
|
currentModule,
|
|
"context", // <- not really context but exercises is reserved for reading, listening and level
|
|
{
|
|
method: 'GET',
|
|
queryParams
|
|
},
|
|
(data: any) => {
|
|
switch (sectionId) {
|
|
case 1:
|
|
return [{
|
|
prompts: data.questions,
|
|
first_topic: data.first_topic,
|
|
second_topic: data.second_topic
|
|
}];
|
|
case 2:
|
|
return [{
|
|
topic: data.topic,
|
|
question: data.question,
|
|
prompts: data.prompts,
|
|
suffix: data.suffix
|
|
}];
|
|
case 3:
|
|
return [{
|
|
title: data.topic,
|
|
prompts: data.questions
|
|
}];
|
|
default:
|
|
return [data];
|
|
}
|
|
}
|
|
);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [localSettings, difficulty]);
|
|
|
|
const onTopicChange = useCallback((topic: string) => {
|
|
updateLocalAndScheduleGlobal({ topic });
|
|
}, [updateLocalAndScheduleGlobal]);
|
|
|
|
const onSecondTopicChange = useCallback((topic: string) => {
|
|
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={preview}
|
|
canPreview={canPreviewOrSubmit}
|
|
canSubmit={canPreviewOrSubmit}
|
|
submitModule={submitSpeaking}
|
|
>
|
|
<Dropdown
|
|
title="Generate Script"
|
|
module={currentModule}
|
|
open={localSettings.isExerciseDropdownOpen}
|
|
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="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
|
|
key={`section-${focusedSection}`}
|
|
type="text"
|
|
placeholder="Topic"
|
|
name="category"
|
|
onChange={onTopicChange}
|
|
roundness="full"
|
|
value={localSettings.topic}
|
|
/>
|
|
</div>
|
|
{focusedSection === 1 &&
|
|
<div className="flex flex-col flex-grow gap-4 px-2">
|
|
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
|
|
<Input
|
|
key={`section-${focusedSection}`}
|
|
type="text"
|
|
placeholder="Topic"
|
|
name="category"
|
|
onChange={onSecondTopicChange}
|
|
roundness="full"
|
|
value={localSettings.secondTopic}
|
|
/>
|
|
</div>
|
|
}
|
|
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
|
|
<GenerateBtn
|
|
module={currentModule}
|
|
genType="context"
|
|
sectionId={focusedSection}
|
|
generateFnc={generateScript}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Dropdown>
|
|
<Dropdown
|
|
title="Generate Video"
|
|
module={currentModule}
|
|
open={localSettings.isGenerateAudioOpen}
|
|
disabled={!canGenerate}
|
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)}
|
|
>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export default SpeakingSettings;
|