412 lines
20 KiB
TypeScript
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;
|