Added Speaking to level, fixed a bug where it was causing level to crash if the listening was already created and the section was switched, added true false exercises to listening
This commit is contained in:
@@ -55,47 +55,180 @@ const LevelSettings: React.FC = () => {
|
||||
const readingSection = section.readingSection;
|
||||
const listeningSection = section.listeningSection;
|
||||
|
||||
const canPreview = currentSection.exercises.length > 0;
|
||||
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 = () => {
|
||||
const submitLevel = async () => {
|
||||
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 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: false,
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/level', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
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 exercise = s.state as LevelPart;
|
||||
const part = s.state as LevelPart;
|
||||
return {
|
||||
...exercise,
|
||||
...part,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
@@ -115,7 +248,6 @@ const LevelSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises.find((ex) => ex.id === focusedExercise.id) as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Part ${focusedSection}`}
|
||||
@@ -123,8 +255,8 @@ const LevelSettings: React.FC = () => {
|
||||
module="level"
|
||||
introPresets={[]}
|
||||
preview={preview}
|
||||
canPreview={canPreview}
|
||||
canSubmit={canPreview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitLevel}
|
||||
>
|
||||
<div>
|
||||
@@ -211,7 +343,6 @@ const LevelSettings: React.FC = () => {
|
||||
/>
|
||||
</Dropdown>
|
||||
</div >
|
||||
{/*
|
||||
<div>
|
||||
<Dropdown title="Add Speaking Exercises" className={
|
||||
clsx(
|
||||
@@ -225,19 +356,19 @@ const LevelSettings: React.FC = () => {
|
||||
open={localSettings.isSpeakingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<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.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 border border-ielts-speaking"}
|
||||
open={localSettings.isSpeakingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="space-y-2 px-2 pb-2">
|
||||
<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}
|
||||
@@ -245,31 +376,33 @@ const LevelSettings: React.FC = () => {
|
||||
levelSectionId={focusedSection}
|
||||
level
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{speakingExercise !== undefined &&
|
||||
<Dropdown title={`Configure Speaking Exercise ${focusedExercise?.questionId}`} 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 border border-ielts-speaking"}
|
||||
open={localSettings.isSpeakingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<SpeakingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise }}
|
||||
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 >
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user