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:
@@ -37,7 +37,7 @@ export function generate(
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: { sectionId : sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate }
|
||||
payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate }
|
||||
});
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export function generate(
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults }
|
||||
payload: { sectionId: level ? levelSectionId! : sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults }
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -12,16 +12,17 @@ interface Props {
|
||||
genType: Generating;
|
||||
generateFnc: (sectionId: number) => void
|
||||
className?: string;
|
||||
levelId?: number;
|
||||
level?: boolean;
|
||||
}
|
||||
|
||||
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, levelId }) => {
|
||||
const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == levelId ? levelId : sectionId));
|
||||
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false }) => {
|
||||
const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const generating = section?.generating;
|
||||
const genResult = section?.genResult;
|
||||
const levelGenerating = section?.levelGenerating;
|
||||
const levelGenResults = section?.levelGenResults;
|
||||
|
||||
useEffect(()=> {
|
||||
const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType);
|
||||
@@ -29,7 +30,7 @@ const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc,
|
||||
setLoading(gen);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [generating, levelGenerating])
|
||||
}, [generating, levelGenerating, levelGenResults, genResult])
|
||||
|
||||
if (section === undefined) return <></>;
|
||||
|
||||
@@ -42,7 +43,7 @@ const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc,
|
||||
className
|
||||
)}
|
||||
disabled={loading}
|
||||
onClick={loading ? () => { } : () => generateFnc(levelId ? levelId : sectionId)}
|
||||
onClick={loading ? () => { } : () => generateFnc(sectionId)}
|
||||
>
|
||||
{loading ? (
|
||||
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import Dropdown from "./SettingsDropdown";
|
||||
import { LevelSectionSettings } from "@/stores/examEditor/types";
|
||||
import { LevelPart } from '@/interfaces/exam';
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
@@ -19,13 +20,15 @@ const SectionPicker: React.FC<Props> = ({
|
||||
}) => {
|
||||
const { dispatch } = useExamEditorStore();
|
||||
const [selectedValue, setSelectedValue] = React.useState<number | undefined>(undefined);
|
||||
|
||||
|
||||
const sectionState = useExamEditorStore(state =>
|
||||
state.modules["level"].sections.find((s) => s.sectionId === sectionId)
|
||||
);
|
||||
|
||||
|
||||
const state = sectionState?.state as LevelPart;
|
||||
|
||||
if (sectionState === undefined) return null;
|
||||
|
||||
|
||||
const { readingSection, listeningSection } = sectionState;
|
||||
const currentValue = selectedValue ?? (module === "reading" ? readingSection : listeningSection);
|
||||
const options = module === "reading" ? [1, 2, 3] : [1, 2, 3, 4];
|
||||
@@ -34,16 +37,44 @@ const SectionPicker: React.FC<Props> = ({
|
||||
const handleSectionChange = (value: number) => {
|
||||
const newValue = currentValue === value ? undefined : value;
|
||||
setSelectedValue(newValue);
|
||||
|
||||
let update = {};
|
||||
if (module == "reading") {
|
||||
update = {
|
||||
text: undefined
|
||||
}
|
||||
} else {
|
||||
if (state.audio?.source) {
|
||||
URL.revokeObjectURL(state.audio.source)
|
||||
}
|
||||
update = {
|
||||
audio: undefined,
|
||||
script: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: "level",
|
||||
field: module === "reading" ? "readingSection" : "listeningSection",
|
||||
value: newValue
|
||||
update: {
|
||||
...state,
|
||||
...update
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: "level",
|
||||
field: module === "reading" ? "readingSection" : "listeningSection",
|
||||
value: newValue
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
@@ -69,7 +100,7 @@ const SectionPicker: React.FC<Props> = ({
|
||||
className={`
|
||||
flex items-center space-x-3 font-semibold cursor-pointer p-2 rounded
|
||||
transition-colors duration-200
|
||||
${currentValue === num
|
||||
${currentValue === num
|
||||
? `bg-ielts-${module}/90 text-white`
|
||||
: `hover:bg-ielts-${module}/70 text-gray-700`}
|
||||
`}
|
||||
@@ -81,7 +112,7 @@ const SectionPicker: React.FC<Props> = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentValue === num}
|
||||
onChange={() => {}}
|
||||
onChange={() => { }}
|
||||
className={`
|
||||
h-5 w-5 cursor-pointer
|
||||
accent-ielts-${module}
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -184,19 +184,16 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
|
||||
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
|
||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||
>
|
||||
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4">
|
||||
<span className="bg-gray-100 px-3.5 py-2.5 rounded-l-lg border border-r-0 border-gray-300">
|
||||
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4 gap-2 p-2">
|
||||
<span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
|
||||
Generate audio recording for this section
|
||||
</span>
|
||||
<div className="-ml-2.5">
|
||||
<GenerateBtn
|
||||
module="listening"
|
||||
genType="audio"
|
||||
sectionId={levelId ? levelId : focusedSection}
|
||||
generateFnc={generateAudio}
|
||||
levelId={focusedSection}
|
||||
/>
|
||||
</div>
|
||||
<GenerateBtn
|
||||
module="listening"
|
||||
genType="audio"
|
||||
sectionId={levelId ? levelId : focusedSection}
|
||||
generateFnc={generateAudio}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
@@ -11,6 +11,7 @@ import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/inte
|
||||
import { toast } from "react-toastify";
|
||||
import { generateVideos } from "../Shared/generateVideos";
|
||||
import { Module } from "@/interfaces";
|
||||
import useCanGenerate from "./useCanGenerate";
|
||||
|
||||
export interface Avatar {
|
||||
name: string;
|
||||
@@ -23,16 +24,19 @@ interface Props {
|
||||
section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart;
|
||||
level?: boolean;
|
||||
module?: Module;
|
||||
id?: string;
|
||||
sectionId?: number;
|
||||
}
|
||||
|
||||
const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, section, level, module = "speaking" }) => {
|
||||
const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, section, level = false, module = "speaking", id, sectionId }) => {
|
||||
|
||||
const { currentModule, speakingAvatars, dispatch } = useExamEditorStore();
|
||||
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
const { currentModule, speakingAvatars, dispatch, modules } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule])
|
||||
const state = sections.find((s) => s.sectionId === sectionId);
|
||||
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
|
||||
|
||||
const generateScript = useCallback((sectionId: number) => {
|
||||
const generateScript = useCallback((scriptSectionId: number) => {
|
||||
const queryParams: {
|
||||
difficulty: string;
|
||||
first_topic?: string;
|
||||
@@ -40,7 +44,7 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
topic?: string;
|
||||
} = { difficulty };
|
||||
|
||||
if (sectionId === 1) {
|
||||
if (scriptSectionId === 1) {
|
||||
if (localSettings.speakingTopic) {
|
||||
queryParams['first_topic'] = localSettings.speakingTopic;
|
||||
}
|
||||
@@ -52,17 +56,16 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
queryParams['topic'] = localSettings.speakingTopic;
|
||||
}
|
||||
}
|
||||
|
||||
generate(
|
||||
sectionId,
|
||||
currentModule,
|
||||
"speakingScript",
|
||||
level ? section.sectionId! : focusedSection,
|
||||
"speaking",
|
||||
`${id ? `${id}-` : ''}speakingScript`,
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams
|
||||
},
|
||||
(data: any) => {
|
||||
switch (sectionId) {
|
||||
switch (level ? section.sectionId! : focusedSection) {
|
||||
case 1:
|
||||
return [{
|
||||
prompts: data.questions,
|
||||
@@ -84,10 +87,12 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
default:
|
||||
return [data];
|
||||
}
|
||||
}
|
||||
},
|
||||
sectionId,
|
||||
level
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings, difficulty]);
|
||||
|
||||
}, [difficulty, level, section.sectionId, focusedSection, id, sectionId, localSettings.speakingTopic, localSettings.speakingSecondTopic]);
|
||||
|
||||
const onTopicChange = useCallback((speakingTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ speakingTopic });
|
||||
@@ -97,39 +102,33 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
updateLocalAndScheduleGlobal({ speakingSecondTopic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
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 canGenerate = useCanGenerate({
|
||||
section,
|
||||
sections,
|
||||
id,
|
||||
focusedSection
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGenerate) {
|
||||
updateLocalAndScheduleGlobal({ isGenerateVideoOpen: false }, false);
|
||||
}
|
||||
})();
|
||||
}, [canGenerate, updateLocalAndScheduleGlobal]);
|
||||
|
||||
const generateVideoCallback = useCallback((sectionId: number) => {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "video" } })
|
||||
if (level) {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: "level", field: "levelGenerating", value: [...state!.levelGenerating, `${id ? `${id}-` : ''}video`] } })
|
||||
} else {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId: focusedSection, module: "speaking", field: "generating", value: "video" } })
|
||||
}
|
||||
|
||||
generateVideos(
|
||||
section as InteractiveSpeakingExercise | SpeakingExercise,
|
||||
sectionId,
|
||||
level ? section.sectionId! : focusedSection,
|
||||
selectedAvatar,
|
||||
speakingAvatars
|
||||
).then((results) => {
|
||||
switch (sectionId) {
|
||||
switch (level ? section.sectionId! : focusedSection) {
|
||||
case 1:
|
||||
case 3: {
|
||||
const interactiveSection = section as InteractiveSpeakingExercise;
|
||||
@@ -137,22 +136,40 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
...prompt,
|
||||
video_url: results[index].url || ''
|
||||
}));
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, module: currentModule, field: "genResult", value:
|
||||
{ generating: "video", result: [{ prompts: updatedPrompts }], module: module }
|
||||
}
|
||||
})
|
||||
if (level) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, field: "levelGenResults", value: [...state!.levelGenResults,
|
||||
{ generating: `${id ? `${id}-` : ''}video`, result: [{ prompts: updatedPrompts }] }], module: "level"
|
||||
}
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId: focusedSection, module: "speaking", field: "genResult", value:
|
||||
{ generating: "video", result: [{ prompts: updatedPrompts }], module: module }
|
||||
}
|
||||
})
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
if (results[0]?.url) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, module: currentModule, field: "genResult", value:
|
||||
{ generating: "video", result: [{ video_url: results[0].url }], module: module }
|
||||
}
|
||||
})
|
||||
if (level) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, field: "levelGenResults", value: [...state!.levelGenResults,
|
||||
{ generating: `${id ? `${id}-` : ''}video`, result: [{ video_url: results[0].url }] }], module: "level"
|
||||
}
|
||||
})
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId: focusedSection, module, field: "genResult", value:
|
||||
{ generating: 'video', result: [{ video_url: results[0].url }], module: "speaking" }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -160,8 +177,10 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
}).catch((error) => {
|
||||
toast.error("Failed to generate the video, try again later!")
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAvatar, section]);
|
||||
|
||||
}, [level, section, focusedSection, selectedAvatar, speakingAvatars, dispatch, module, state, id]);
|
||||
|
||||
const secId = level ? section.sectionId! : focusedSection;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -173,11 +192,11 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
|
||||
>
|
||||
|
||||
<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", secId === 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>
|
||||
<label className="font-normal text-base text-mti-gray-dim">{`${secId === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
key={`section-${secId}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
@@ -186,11 +205,11 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
value={localSettings.speakingTopic}
|
||||
/>
|
||||
</div>
|
||||
{focusedSection === 1 &&
|
||||
{secId === 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}`}
|
||||
key={`section-${secId}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
@@ -200,12 +219,13 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
|
||||
<div className={clsx("flex h-16 mb-1", secId === 1 ? "justify-center mt-4" : "self-end")}>
|
||||
<GenerateBtn
|
||||
module="speaking"
|
||||
genType="speakingScript"
|
||||
genType={`${id ? `${id}-` : ''}speakingScript`}
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateScript}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,6 +236,7 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
open={localSettings.isGenerateVideoOpen}
|
||||
disabled={!canGenerate}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
|
||||
>
|
||||
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
@@ -255,9 +276,10 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
|
||||
|
||||
<GenerateBtn
|
||||
module="speaking"
|
||||
genType="video"
|
||||
genType={`${id ? `${id}-` : ''}video`}
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateVideoCallback}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { Section } from '@/stores/examEditor/types';
|
||||
|
||||
interface CheckGenerateProps {
|
||||
section: Section | null;
|
||||
sections: Array<{ sectionId: number; state: Section }>;
|
||||
id?: string;
|
||||
focusedSection: number;
|
||||
}
|
||||
|
||||
const useCanGenerate = ({ section, sections, id, focusedSection }: CheckGenerateProps) => {
|
||||
const checkCanGenerate = useCallback(() => {
|
||||
if (!section) return false;
|
||||
|
||||
|
||||
const exercise = id
|
||||
? (sections.find(s => s.sectionId === 1)?.state as LevelPart)
|
||||
?.exercises?.find(ex => ex.id === id) ?? section
|
||||
: section;
|
||||
|
||||
const sectionId = id ? (exercise as SpeakingExercise | InteractiveSpeakingExercise).sectionId : focusedSection;
|
||||
|
||||
switch (sectionId) {
|
||||
case 1: {
|
||||
const currentSection = exercise as InteractiveSpeakingExercise;
|
||||
return currentSection.first_title &&
|
||||
currentSection.second_title &&
|
||||
currentSection.prompts?.length > 2 &&
|
||||
currentSection.prompts.every(prompt => prompt.text)
|
||||
;
|
||||
}
|
||||
case 2: {
|
||||
const currentSection = exercise as SpeakingExercise;
|
||||
return currentSection.title &&
|
||||
currentSection.text &&
|
||||
currentSection.prompts?.length > 0 &&
|
||||
currentSection.prompts.every(prompt => prompt)
|
||||
;
|
||||
}
|
||||
case 3: {
|
||||
const currentSection = exercise as InteractiveSpeakingExercise;
|
||||
return currentSection.title &&
|
||||
currentSection.prompts?.length > 0 &&
|
||||
currentSection.prompts.every(prompt => prompt.text)
|
||||
;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [section, sections, id, focusedSection]);
|
||||
|
||||
const [canGenerate, setCanGenerate] = useState(checkCanGenerate());
|
||||
|
||||
useEffect(() => {
|
||||
setCanGenerate(checkCanGenerate());
|
||||
}, [checkCanGenerate, section]);
|
||||
|
||||
return canGenerate;
|
||||
};
|
||||
|
||||
export default useCanGenerate;
|
||||
Reference in New Issue
Block a user