Files
encoach_frontend/src/components/ExamEditor/SettingsEditor/level.tsx

412 lines
20 KiB
TypeScript

import { Exercise, InteractiveSpeakingExercise, LevelExam, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import SettingsEditor from ".";
import Option from "@/interfaces/option";
import Dropdown from "@/components/Dropdown";
import clsx from "clsx";
import ExercisePicker from "../ExercisePicker";
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState";
import { LevelSectionSettings } 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/exam";
import openDetachedTab from "@/utils/popout";
import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components";
import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker";
import { getExamById } from "@/utils/exams";
const LevelSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore();
const {
focusedSection,
difficulty,
sections,
minTimer,
access,
} = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
currentModule,
focusedSection
);
const section = sections.find((section) => section.sectionId == focusedSection);
const focusedExercise = section?.focusedExercise;
if (section === undefined) return <></>;
const currentSection = section.state as LevelPart;
const readingSection = section.readingSection;
const listeningSection = section.listeningSection;
const canPreviewOrSubmit = sections.length > 0 && sections.some(s => {
const part = s.state as LevelPart;
return part.exercises.length > 0 && part.exercises.every((exercise) => {
if (exercise.type === 'speaking') {
return exercise.title !== '' &&
exercise.text !== '' &&
exercise.video_url !== '' &&
exercise.prompts.every(prompt => prompt !== '');
} else if (exercise.type === 'interactiveSpeaking') {
if ('first_title' in exercise && 'second_title' in exercise) {
return exercise.first_title !== '' &&
exercise.second_title !== '' &&
exercise.prompts.every(prompt => prompt.video_url !== '') &&
exercise.prompts.length > 2;
}
return exercise.title !== '' &&
exercise.prompts.every(prompt => prompt.video_url !== '');
}
return true;
});
});
const submitLevel = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const partsWithMissingAudio = sections.some(s => {
const part = s.state as LevelPart;
return part.audio && !part.audio.source;
});
if (partsWithMissingAudio) {
toast.error("There are parts with missing audio recordings. Either generate them or remove the listening sections.");
return;
}
try {
const audioFormData = new FormData();
const videoFormData = new FormData();
const audioMap = new Map<number, string>();
const videoMap = new Map<string, string>();
const partsWithAudio = sections.filter(s => (s.state as LevelPart).audio?.source);
await Promise.all(
partsWithAudio.map(async (section) => {
const levelPart = section.state as LevelPart;
const blobUrl = levelPart.audio!.source;
const response = await fetch(blobUrl);
const blob = await response.blob();
audioFormData.append('file', blob, 'audio.mp3');
audioMap.set(section.sectionId, blobUrl);
})
);
await Promise.all(
sections.flatMap(async (section) => {
const levelPart = section.state as LevelPart;
return Promise.all(
levelPart.exercises.map(async (exercise, exerciseIndex) => {
if (exercise.type === "speaking") {
if (exercise.video_url) {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
videoFormData.append('file', blob, 'video.mp4');
videoMap.set(`${section.sectionId}-${exerciseIndex}`, exercise.video_url);
}
} else if (exercise.type === "interactiveSpeaking") {
await Promise.all(
exercise.prompts.map(async (prompt, promptIndex) => {
if (prompt.video_url) {
const response = await fetch(prompt.video_url);
const blob = await response.blob();
videoFormData.append('file', blob, 'video.mp4');
videoMap.set(`${section.sectionId}-${exerciseIndex}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
})
);
const [audioUrls, videoUrls] = await Promise.all([
audioMap.size > 0
? axios.post('/api/storage', audioFormData, {
params: { directory: 'listening_recordings' },
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => response.data.urls)
: [],
videoMap.size > 0
? axios.post('/api/storage', videoFormData, {
params: { directory: 'speaking_videos' },
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => response.data.urls)
: []
]);
const exam: LevelExam = {
parts: sections.map((s) => {
const part = s.state as LevelPart;
const audioIndex = Array.from(audioMap.entries())
.findIndex(([id]) => id === s.sectionId);
const updatedExercises = part.exercises.map((exercise, exerciseIndex) => {
if (exercise.type === "speaking") {
const videoIndex = Array.from(videoMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}`);
return {
...exercise,
video_url: videoIndex !== -1 ? videoUrls[videoIndex] : exercise.video_url
};
} else if (exercise.type === "interactiveSpeaking") {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(videoMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? videoUrls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts
};
}
return exercise;
});
return {
...part,
audio: part.audio ? {
...part.audio,
source: audioIndex !== -1 ? audioUrls[audioIndex] : part.audio.source
} : undefined,
exercises: updatedExercises,
intro: s.settings.currentIntro,
category: s.settings.category
};
}).filter(part => part.exercises.length > 0),
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed.
minTimer,
module: "level",
id: title,
difficulty,
access,
};
const result = await axios.post('/api/exam/level', exam);
playSound("sent");
// Successfully submitted exam
if (result.status === 200) {
toast.success(result.data.message);
} else if (result.status === 207) {
toast.warning(result.data.message);
}
Array.from(audioMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
Array.from(videoMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
parts: sections.map((s) => {
const part = s.state as LevelPart;
return {
...part,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "level",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
access,
} as LevelExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
openDetachedTab("popout?type=Exam&module=level", router)
}
const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises.find((ex) => ex.id === focusedExercise.id) as SpeakingExercise | InteractiveSpeakingExercise;
return (
<SettingsEditor
sectionLabel={`Part ${focusedSection}`}
sectionId={focusedSection}
module="level"
introPresets={[]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitLevel}
>
<div>
<Dropdown title="Add Level Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-level/70 border-ielts-level hover:bg-ielts-level",
"text-white shadow-md transition-all duration-300",
localSettings.isLevelDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isLevelDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isLevelDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="level"
sectionId={focusedSection}
/>
</Dropdown>
</div>
<div>
<Dropdown title="Add Reading Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-reading/70 border-ielts-reading hover:bg-ielts-reading",
"text-white shadow-md transition-all duration-300",
localSettings.isReadingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isReadingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "reading", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, generatePassageDisabled: readingSection === undefined, levelId: readingSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Listening Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-listening/70 border-ielts-listening hover:bg-ielts-listening",
"text-white shadow-md transition-all duration-300",
localSettings.isListeningDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isListeningDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "listening", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ListeningComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, audioContextDisabled: listeningSection === undefined, levelId: listeningSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Writing Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-writing/70 border-ielts-writing hover:bg-ielts-writing",
"text-white shadow-md transition-all duration-300",
localSettings.isWritingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isWritingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="writing"
sectionId={focusedSection}
levelSectionId={focusedSection}
level
/>
</Dropdown>
</div >
<div>
<Dropdown title="Add Speaking Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<Dropdown title="Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingExercisesOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isSpeakingExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingExercisesOpen: isOpen }, false)}
>
<ExercisePicker
module="speaking"
sectionId={focusedSection}
levelSectionId={focusedSection}
level
/>
</Dropdown>
{speakingExercise !== undefined &&
<Dropdown title={`Configure Speaking Exercise #${Number(focusedExercise!.questionId) + 1}`} className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isConfigureExercisesOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isConfigureExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isConfigureExercisesOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise, id: speakingExercise.id, sectionId: focusedSection }}
level
/>
</div>
</Dropdown>
}
</div>
</Dropdown>
</div>
</SettingsEditor >
);
};
export default LevelSettings;