Merged in feature/ExamGenRework (pull request #111)

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:
carlos.mesquita
2024-11-14 11:12:01 +00:00
committed by Tiago Ribeiro
23 changed files with 1122 additions and 292 deletions

View File

@@ -166,7 +166,22 @@ const listening = (section: number) => {
generate() generate()
], ],
module: "listening" module: "listening"
} },
{
label: `Section ${section} - True False`,
type: `listening_${section}`,
icon: FaCheckSquare,
sectionId: section,
extra: [
{
param: "name",
value: "trueFalse"
},
quantity(4, "Quantity of Statements"),
generate()
],
module: "listening"
},
]; ];
if (section === 1 || section === 4) { if (section === 1 || section === 4) {
@@ -285,7 +300,8 @@ const EXERCISES: ExerciseGen[] = [
], ],
module: "level" module: "level"
}, },
{ // Removing this since level supports reading aswell
/*{
label: "Reading Passage: Multiple Choice", label: "Reading Passage: Multiple Choice",
type: "passageUtas", type: "passageUtas",
icon: FaBookOpen, icon: FaBookOpen,
@@ -295,11 +311,11 @@ const EXERCISES: ExerciseGen[] = [
value: "passageUtas" value: "passageUtas"
}, },
// in the utas exam there was only mc so I'm assuming short answers are deprecated // in the utas exam there was only mc so I'm assuming short answers are deprecated
/*{ //{
label: "Short Answers", // label: "Short Answers",
param: "sa_qty", // param: "sa_qty",
value: "10" // value: "10"
},*/ //},
quantity(10, "Multiple Choice Quantity"), quantity(10, "Multiple Choice Quantity"),
{ {
label: "Reading Passage Topic", label: "Reading Passage Topic",
@@ -315,7 +331,7 @@ const EXERCISES: ExerciseGen[] = [
generate() generate()
], ],
module: "level" module: "level"
}, },*/
{ {
label: "Task 1 - Letter", label: "Task 1 - Letter",
type: "writing_letter", type: "writing_letter",

View File

@@ -130,25 +130,51 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
); );
}); });
} else { } else {
/*const newExercises = configurations.map((config) => { configurations.forEach((config) => {
switch (config.type) { let queryParams = Object.fromEntries(
case 'writing_letter': Object.entries({
return { ...writingTask(1), level: true }; topic: config.params.topic as string,
case 'writing_2': first_topic: config.params.first_topic as string,
return { ...writingTask(2), level: true }; second_topic: config.params.second_topic as string,
}).filter(([_, value]) => value && value !== '')
);
let query = Object.keys(queryParams).length === 0 ? undefined : queryParams;
generate(
Number(config.type.split('_')[1]),
"speaking",
config.type,
{
method: 'GET',
queryParams: query
},
(data: any) => {
switch (Number(config.type.split('_')[1])) {
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 [{
topic: data.topic,
questions: data.questions
}];
default:
return [data];
} }
return undefined; },
}).filter((ex) => ex !== undefined); levelSectionId,
dispatch({ level
type: "UPDATE_SECTION_STATE", payload: { );
module: level ? "level" : module as Module, sectionId, update: { });
exercises: [
...(sections.find((s) => s.sectionId = sectionId)?.state as LevelPart).exercises,
...newExercises
]
}
}
})*/
} }
setLocalSelectedExercises([]); setLocalSelectedExercises([]);
setPickerOpen(false); setPickerOpen(false);

View File

@@ -8,7 +8,7 @@ import GenLoader from "../Shared/GenLoader";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useSectionEdit from "../../Hooks/useSectionEdit"; import useSectionEdit from "../../Hooks/useSectionEdit";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs"; import { BsFileText } from "react-icons/bs";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
import { RiVideoLine } from "react-icons/ri"; import { RiVideoLine } from "react-icons/ri";
@@ -23,10 +23,9 @@ interface Props {
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => { const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const { dispatch } = useExamEditorStore(); const { dispatch } = useExamEditorStore();
const [local, setLocal] = useState(exercise); const [local, setLocal] = useState(exercise);
const [currentVideoIndex, setCurrentVideoIndex] = useState(0); const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const { generating, genResult, state } = useExamEditorStore( const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
); );
@@ -34,48 +33,94 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "s
sectionId, sectionId,
onSave: () => { onSave: () => {
setEditing(false); setEditing(false);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local, module: module } });
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: local, module }
});
}
if (genResult) { if (genResult) {
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
sectionId, sectionId,
module: module, module,
field: "genResult", field: "genResult",
value: undefined value: undefined
} }
}); });
} }
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
if (module === "level" && speakingScript) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenResults",
value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`),
module
}
});
}
}, },
onDiscard: () => { onDiscard: () => {
setLocal(exercise); setLocal(exercise);
}, },
onPractice: () => { onPractice: () => {
const updatedExercise = { const updatedLocal = { ...local, isPractice: !local.isPractice };
setLocal(updatedLocal);
if (module === "level") {
const updatedState = {
...state, ...state,
isPractice: !local.isPractice exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
}; };
setLocal((prev) => ({...prev, isPractice: !local.isPractice})) dispatch({
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } }); type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
}
}, },
}); });
useEffect(() => { useEffect(() => {
if (genResult && generating === "speakingScript") { if (genResult && generating === "speakingScript") {
setEditing(true); const updatedLocal = {
setLocal({
...local, ...local,
title: genResult.result[0].title, title: genResult.result[0].title,
prompts: genResult.result[0].prompts.map((item: any) => ({ prompts: genResult.result[0].prompts.map((item: any) => ({
text: item || "", text: item || "",
video_url: "" video_url: ""
})) }))
}); };
setEditing(true);
setLocal(updatedLocal);
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
sectionId, sectionId,
module: module, module,
field: "generating", field: "generating",
value: undefined value: undefined
} }
@@ -84,6 +129,90 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "s
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]); }, [genResult, generating]);
useEffect(() => {
if (genResult && generating === "video") {
const updatedLocal = { ...local, prompts: genResult.result[0].prompts };
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`);
if (speakingScript && isGenerating) {
const updatedLocal = {
...local,
title: speakingScript.result[0].title,
prompts: speakingScript.result[0].prompts.map((item: any) => ({
text: item || "",
video_url: ""
}))
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
useEffect(() => {
const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`);
const isGenerating = levelGenerating?.includes(`${local.id}-video`);
if (speakingVideo && isGenerating) {
const updatedLocal = { ...local, prompts: speakingVideo.result[0].prompts };
setLocal(updatedLocal);
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-video`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
const addPrompt = () => { const addPrompt = () => {
setLocal(prev => ({ setLocal(prev => ({
...prev, ...prev,
@@ -150,7 +279,7 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "s
module="speaking" module="speaking"
/> />
</div> </div>
{generating && generating === "speakingScript" ? ( {(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? (
<GenLoader module={module} /> <GenLoader module={module} />
) : ( ) : (
<> <>
@@ -206,7 +335,7 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "s
</CardContent> </CardContent>
</Card> </Card>
)} )}
{generating && generating === "video" && {(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) &&
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." /> <GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
} }
<Card> <Card>

View File

@@ -6,7 +6,7 @@ import Header from "../../Shared/Header";
import GenLoader from "../Shared/GenLoader"; import GenLoader from "../Shared/GenLoader";
import useSectionEdit from "../../Hooks/useSectionEdit"; import useSectionEdit from "../../Hooks/useSectionEdit";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs"; import { BsFileText } from "react-icons/bs";
import { RiVideoLine } from 'react-icons/ri'; import { RiVideoLine } from 'react-icons/ri';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'; import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
@@ -19,7 +19,7 @@ interface Props {
} }
const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => { const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const {dispatch} = useExamEditorStore(); const { dispatch } = useExamEditorStore();
const [local, setLocal] = useState(() => { const [local, setLocal] = useState(() => {
const defaultPrompts = [ const defaultPrompts = [
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" }, { text: "Hello my name is {avatar}, what is yours?", video_url: "" },
@@ -31,7 +31,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
const [currentVideoIndex, setCurrentVideoIndex] = useState(0); const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const { generating, genResult , state} = useExamEditorStore( const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
); );
@@ -39,18 +39,48 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
sectionId, sectionId,
onSave: () => { onSave: () => {
setEditing(false); setEditing(false);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local, module } });
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: local, module }
});
}
if (genResult) { if (genResult) {
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
sectionId, sectionId,
module: module, module,
field: "genResult", field: "genResult",
value: undefined value: undefined
} }
}); });
} }
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
if (module === "level" && speakingScript) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenResults",
value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`),
module
}
});
}
}, },
onDiscard: () => { onDiscard: () => {
setLocal({ setLocal({
@@ -63,36 +93,52 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
}); });
}, },
onPractice: () => { onPractice: () => {
const updatedExercise = { const updatedLocal = { ...local, isPractice: !local.isPractice };
setLocal(updatedLocal);
if (module === "level") {
const updatedState = {
...state, ...state,
isPractice: !local.isPractice exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
}; };
setLocal((prev) => ({...prev, isPractice: !local.isPractice})) dispatch({
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } }); type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
}
}, },
}); });
useEffect(() => { useEffect(() => {
if (genResult && generating === "speakingScript") { if (genResult && generating === "speakingScript") {
setEditing(true); const updatedLocal = {
setLocal(prev => ({ ...local,
...prev,
first_title: genResult.result[0].first_topic, first_title: genResult.result[0].first_topic,
second_title: genResult.result[0].second_topic, second_title: genResult.result[0].second_topic,
prompts: [ prompts: [
prev.prompts[0], local.prompts[0],
prev.prompts[1], local.prompts[1],
...genResult.result[0].prompts.map((item: any) => ({ ...genResult.result[0].prompts.map((item: any) => ({
text: item, text: item,
video_url: "" video_url: ""
})) }))
] ]
})); };
setEditing(true);
setLocal(updatedLocal);
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
sectionId, sectionId,
module: module, module,
field: "generating", field: "generating",
value: undefined value: undefined
} }
@@ -101,6 +147,96 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]); }, [genResult, generating]);
useEffect(() => {
if (genResult && generating === "video") {
const updatedLocal = { ...local, prompts: genResult.result[0].prompts };
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
useEffect(() => {
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`);
if (speakingScript && isGenerating) {
const updatedLocal = {
...local,
first_title: speakingScript.result[0].first_topic,
second_title: speakingScript.result[0].second_topic,
prompts: [
local.prompts[0],
local.prompts[1],
...speakingScript.result[0].prompts.map((item: any) => ({
text: item,
video_url: ""
}))
]
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
useEffect(() => {
const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`);
const isGenerating = levelGenerating?.includes(`${local.id}-video`);
if (speakingVideo && isGenerating) {
const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url };
setLocal(updatedLocal);
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: levelGenerating.filter((g) => g !== `${local.id}-video`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
const addPrompt = () => { const addPrompt = () => {
setLocal(prev => ({ setLocal(prev => ({
...prev, ...prev,
@@ -179,7 +315,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
module="speaking" module="speaking"
/> />
</div> </div>
{generating && generating === "speakingScript" ? ( {(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? (
<GenLoader module={module} /> <GenLoader module={module} />
) : ( ) : (
<> <>
@@ -323,7 +459,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
</CardContent> </CardContent>
</Card> </Card>
)} )}
{generating && generating === "video" && {(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) &&
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." /> <GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
} }
<Card> <Card>

View File

@@ -1,7 +1,7 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai'; import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
import { SpeakingExercise } from "@/interfaces/exam"; import { LevelPart, SpeakingExercise } from "@/interfaces/exam";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useSectionEdit from "../../Hooks/useSectionEdit"; import useSectionEdit from "../../Hooks/useSectionEdit";
@@ -20,61 +20,104 @@ interface Props {
module?: Module; module?: Module;
} }
const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => { const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const { dispatch } = useExamEditorStore(); const { dispatch } = useExamEditorStore();
const [local, setLocal] = useState(exercise); const [local, setLocal] = useState(exercise);
const { generating, genResult, state } = useExamEditorStore( const { sections } = useExamEditorStore((store) => store.modules[module]);
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! const section = sections.find((section) => section.sectionId === sectionId)!;
); const { generating, genResult, state, levelGenResults, levelGenerating } = section;
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({ const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
sectionId, sectionId,
onSave: () => { onSave: () => {
setEditing(false); setEditing(false);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local , module} });
if (module === "level") {
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: local, module }
});
}
if (genResult) { if (genResult) {
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
sectionId, sectionId,
module: module, module,
field: "genResult", field: "genResult",
value: undefined value: undefined
} }
}); });
} }
const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`)
if (module === "level" && speakingScript) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenResults",
value: section!.levelGenResults.filter((res) => res.generating !== `${local.id ? `${local.id}-` : ''}speakingScript`),
module
}
})
}
}, },
onDiscard: () => { onDiscard: () => {
setLocal(exercise); setLocal(exercise);
}, },
onPractice: () => { onPractice: () => {
const updatedExercise = { const updatedLocal = { ...local, isPractice: !local.isPractice };
setLocal(updatedLocal);
if (module === "level") {
const updatedState = {
...state, ...state,
isPractice: !local.isPractice exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
}; };
setLocal((prev) => ({...prev, isPractice: !local.isPractice})) dispatch({
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } }); type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
} else {
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
}
}, },
}); });
useEffect(() => { useEffect(() => {
if (genResult && generating === "speakingScript") { if (genResult && generating === "speakingScript") {
setEditing(true); const updatedLocal = {
setLocal({
...local, ...local,
title: genResult.result[0].topic, title: genResult.result[0].topic,
text: genResult.result[0].question, text: genResult.result[0].question,
prompts: genResult.result[0].prompts prompts: genResult.result[0].prompts
}); };
setEditing(true);
setLocal(updatedLocal);
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
sectionId, sectionId,
module: module, module,
field: "generating", field: "generating",
value: undefined value: undefined
} }
@@ -85,13 +128,19 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
useEffect(() => { useEffect(() => {
if (genResult && generating === "video") { if (genResult && generating === "video") {
setLocal({...local, video_url: genResult.result[0].video_url}); const updatedLocal = { ...local, video_url: genResult.result[0].video_url };
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult.result[0].video_url} , module} }); setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedLocal, module }
});
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
sectionId, sectionId,
module: module, module,
field: "generating", field: "generating",
value: undefined value: undefined
} }
@@ -100,6 +149,62 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]); }, [genResult, generating]);
useEffect(() => {
const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`);
const generating = levelGenerating.find((res) => res === `${local.id}-speakingScript`);
if (speakingScript && generating) {
const updatedLocal = {
...local,
title: speakingScript.result[0].topic,
text: speakingScript.result[0].question,
prompts: speakingScript.result[0].prompts
};
setEditing(true);
setLocal(updatedLocal);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}speakingScript`),
module
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
useEffect(() => {
const speakingVideo = levelGenResults.find((res) => res.generating === `${local.id}-video`);
const generating = levelGenerating.find((res) => res === `${local.id}-video`);
if (speakingVideo && generating) {
const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url };
setLocal(updatedLocal);
const updatedState = {
...state,
exercises: (state as LevelPart).exercises.map((ex) =>
ex.id === local.id ? updatedLocal : ex
)
};
dispatch({
type: "UPDATE_SECTION_STATE",
payload: { sectionId, update: updatedState, module }
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId,
field: "levelGenerating",
value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}video`),
module
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, levelGenerating]);
const addPrompt = () => { const addPrompt = () => {
setLocal(prev => ({ setLocal(prev => ({
...prev, ...prev,
@@ -143,7 +248,7 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
<> <>
<div className='relative pb-4'> <div className='relative pb-4'>
<Header <Header
title={`Speaking ${sectionId} Script`} title={`Speaking ${module === "level" ? local.sectionId : sectionId} Script`}
description='Generate or write the script for the video.' description='Generate or write the script for the video.'
editing={editing} editing={editing}
handleSave={handleSave} handleSave={handleSave}
@@ -154,7 +259,7 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
module="speaking" module="speaking"
/> />
</div> </div>
{generating && generating === "speakingScript" ? ( {((generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`))) ? (
<GenLoader module={module} /> <GenLoader module={module} />
) : ( ) : (
<> <>
@@ -263,14 +368,14 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }
</div> </div>
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
<video controls className="w-full rounded-xl"> <video controls className="w-full rounded-xl">
<source src={local.video_url } /> <source src={local.video_url} />
</video> </video>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
} }
{generating && generating === "video" && {((generating === "video") || (levelGenerating.find((g) => g === `${local.id}-video`) !== undefined)) &&
<GenLoader module={module} custom="Generating the video ... This may take a while ..." /> <GenLoader module={module} custom="Generating the video ... This may take a while ..." />
} }
<Card> <Card>

View File

@@ -8,28 +8,20 @@ import { Module } from "@/interfaces";
interface Props { interface Props {
sectionId: number; sectionId: number;
exercise: SpeakingExercise | InteractiveSpeakingExercise; exercise: SpeakingExercise | InteractiveSpeakingExercise;
qId?: number;
module: Module; module: Module;
} }
const Speaking: React.FC<Props> = ({ sectionId, exercise, qId, module = "speaking" }) => { const Speaking: React.FC<Props> = ({ sectionId, module = "speaking" }) => {
const { dispatch } = useExamEditorStore();
const { state } = useExamEditorStore( const { state } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
); );
const onFocus = () => {
if (qId) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module, sectionId, field: "focusedExercise", value: { questionId: qId, id: exercise.id } } })
}
}
return ( return (
<> <>
<div tabIndex={0} className="mx-auto p-3 space-y-6" onFocus={onFocus}> <div className="mx-auto p-3 space-y-6">
<div className="p-4"> <div className="p-4">
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
{sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />} {sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise } />}
{sectionId === 2 && <Speaking2 sectionId={sectionId} exercise={state as SpeakingExercise} />} {sectionId === 2 && <Speaking2 sectionId={sectionId} exercise={state as SpeakingExercise} />}
{sectionId === 3 && <InteractiveSpeaking sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />} {sectionId === 3 && <InteractiveSpeaking sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}
</div> </div>

View File

@@ -100,7 +100,7 @@ const Writing: React.FC<Props> = ({ sectionId, exercise, module, index }) => {
description='Generate or edit the instructions for the task' description='Generate or edit the instructions for the task'
editing={editing} editing={editing}
handleSave={handleSave} handleSave={handleSave}
handleDelete={handleDelete} handleDelete={module == "level" ? handleDelete : undefined}
handleEdit={handleEdit} handleEdit={handleEdit}
handleDiscard={handleDiscard} handleDiscard={handleDiscard}
handlePractice={handlePractice} handlePractice={handlePractice}

View File

@@ -51,6 +51,12 @@ const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection
}, },
}); });
useEffect(()=> {
if (listeningPart.script == undefined) {
setScriptLocal(undefined);
}
}, [listeningPart])
useEffect(() => { useEffect(() => {
if (genResult && generating === "listeningScript") { if (genResult && generating === "listeningScript") {
setEditing(true); setEditing(true);

View File

@@ -55,6 +55,13 @@ const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) =
} }
}); });
useEffect(()=> {
if (readingPart.text === undefined) {
setTitle('');
setContent('');
}
}, [readingPart])
useEffect(() => { useEffect(() => {
if (genResult && genResult.generating === "passage") { if (genResult && genResult.generating === "passage") {
setEditing(true); setEditing(true);

View File

@@ -7,7 +7,9 @@ import TrueFalse from "../../Exercises/TrueFalse";
import fillBlanks from "./fillBlanks"; import fillBlanks from "./fillBlanks";
import MatchSentences from "../../Exercises/MatchSentences"; import MatchSentences from "../../Exercises/MatchSentences";
import Writing from "../../Exercises/Writing"; import Writing from "../../Exercises/Writing";
import Speaking from "../../Exercises/Speaking"; import Speaking2 from "../../Exercises/Speaking/Speaking2";
import Speaking1 from "../../Exercises/Speaking/Speaking1";
import InteractiveSpeaking from "../../Exercises/Speaking/InteractiveSpeaking";
const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => { const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
const items: ExerciseItem[] = exercises.map((exercise, index) => { const items: ExerciseItem[] = exercises.map((exercise, index) => {
@@ -81,39 +83,40 @@ const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseIte
}; };
case "speaking": case "speaking":
return { return {
exerciseId: exercise.id,
id: index.toString(), id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
type={`Speaking Section 2`} type={`Speaking Section 2: Question`}
firstId={exercise.sectionId!.toString()} firstId={(index+1).toString()}
lastId={exercise.sectionId!.toString()} lastId={(index+1).toString()}
prompt={exercise.prompts[2]} prompt={exercise.prompts[2]}
/> />
), ),
content: <Speaking key={exercise.id} exercise={exercise} sectionId={sectionId} qId={index} module="level" /> content: <Speaking2 key={exercise.id} exercise={exercise} sectionId={sectionId} module="level" />
}; };
case "interactiveSpeaking": case "interactiveSpeaking":
const content = exercise.sectionId === 1 ? <Speaking1 key={exercise.id} exercise={exercise} sectionId={sectionId} module="level" /> :
<InteractiveSpeaking key={exercise.id} exercise={exercise} sectionId={sectionId} module="level"/>
return { return {
exerciseId: exercise.id,
id: index.toString(), id: index.toString(),
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
type={`Speaking Section 2`} type={`${exercise.sectionId === 1 ? 'Speaking Section 1': 'Interactive Speaking'}: Question`}
firstId={exercise.sectionId!.toString()} firstId={(index+1).toString()}
lastId={exercise.sectionId!.toString()} lastId={(index+1).toString()}
prompt={exercise.prompts[2].text} prompt={exercise.prompts[2].text}
/> />
), ),
content: <Speaking key={exercise.id} exercise={exercise} sectionId={sectionId} qId={index} module="level" /> content: content
}; };
default: default:
return {} as unknown as ExerciseItem; return {} as unknown as ExerciseItem;
} }
}).filter(isExerciseItem); }).filter(isExerciseItem);
/*return mappedItems.filter((item): item is ExerciseItem =>
item !== null && isExerciseItem(item)
);*/
return items; return items;
}; };

View File

@@ -1,6 +1,6 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import SortableSection from "../../Shared/SortableSection"; import SortableSection from "../../Shared/SortableSection";
import { Exercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { Exercise, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import ExerciseItem from "./types"; import ExerciseItem from "./types";
import Dropdown from "@/components/Dropdown"; import Dropdown from "@/components/Dropdown";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
@@ -20,6 +20,7 @@ import React from "react";
import getExerciseItems from "./exercises"; import getExerciseItems from "./exercises";
import { Action } from "@/stores/examEditor/reducers"; import { Action } from "@/stores/examEditor/reducers";
import { writingTask } from "@/stores/examEditor/sections"; import { writingTask } from "@/stores/examEditor/sections";
import { createSpeakingExercise } from "./speaking";
interface QuestionItemsResult { interface QuestionItemsResult {
@@ -120,7 +121,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
module: "level", module: "level",
update: { update: {
exercises: [...(sectionState as ExamPart).exercises, exercises: [...(sectionState as ExamPart).exercises,
...results.map((res)=> { ...results.map((res) => {
return { return {
...writingTask(res.generating === "writing_letter" ? 1 : 2), ...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt, prompt: res.result[0].prompt,
@@ -156,6 +157,46 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]); }, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("speaking"))) {
const results = levelGenResults.filter(res => res.generating.startsWith("speaking"));
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises,
...results.map(createSpeakingExercise)
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating))
}
}
] as Action[];
updates.forEach(update => dispatch(update));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
const currentSection = sections.find((s) => s.sectionId === sectionId)!; const currentSection = sections.find((s) => s.sectionId === sectionId)!;
@@ -199,7 +240,13 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const filteredItems = (questions.items ?? []).filter(isValidItem); const filteredItems = (questions.items ?? []).filter(isValidItem);
// ############################################################################# // #############################################################################
console.log(levelGenerating);
const onFocus = (questionId: string, id: string | undefined) => {
if (id) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id} } })
}
}
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -223,7 +270,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
customTitle={item.label} customTitle={item.label}
contentWrapperClassName="rounded-xl" contentWrapperClassName="rounded-xl"
> >
<div className="p-4 shadow-inner border border-gray-200 bg-gray-50 rounded-xl"> <div tabIndex={4} className="p-4 shadow-inner border border-gray-200 bg-gray-50 rounded-xl" onFocus={() => onFocus(item.id, item.exerciseId)}>
{item.content} {item.content}
</div> </div>
</Dropdown> </Dropdown>
@@ -237,9 +284,9 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
{currentModule === "level" && ( {currentModule === "level" && (
<> <>
{ {
questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing")) && generating !== "exercises" questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises"
&& background(<span className="flex justify-center">Generated exercises will appear here!</span>)} && background(<span className="flex justify-center">Generated exercises will appear here!</span>)}
{levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing")) && <GenLoader module={currentModule} className="mt-4" />} {levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && <GenLoader module={currentModule} className="mt-4" />}
</>) </>)
} }
</DndContext > </DndContext >

View File

@@ -0,0 +1,47 @@
import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam";
import { speakingTask } from "@/stores/examEditor/sections";
export const createSpeakingExercise = (res: any) => {
const taskNumber = Number(res.generating.split("_")[1]);
const baseExercise = speakingTask(taskNumber);
return {
...baseExercise,
...getSpeakingTaskData(taskNumber, res.result[0])
} as SpeakingExercise | InteractiveSpeakingExercise;
};
const getSpeakingTaskData = (taskNumber: number, data: any) => {
switch (taskNumber) {
case 1:
return {
first_title: data.first_topic,
second_title: data.second_topic,
prompts: [
...data.prompts.map((item: any) => ({
text: item,
video_url: ""
}))
],
sectionId: 1,
};
case 2:
return {
title: data.topic,
text: data.question,
prompts: data.prompts,
sectionId: 2,
type: "speaking"
};
case 3:
return {
title: data.topic,
prompts: data.questions.map((item: any) => ({
text: item || "",
video_url: ""
})),
sectionId: 3,
};
default:
return data;
}
};

View File

@@ -3,6 +3,7 @@ export default interface ExerciseItem {
sectionId: number; sectionId: number;
label: React.ReactNode; label: React.ReactNode;
content: React.ReactNode; content: React.ReactNode;
exerciseId?: string;
} }
export function isExerciseItem(item: unknown): item is ExerciseItem { export function isExerciseItem(item: unknown): item is ExerciseItem {

View File

@@ -0,0 +1,66 @@
import { Action } from "@/stores/examEditor/reducers";
import { ExamPart, Generating } from "@/stores/examEditor/types";
import { createSpeakingExercise } from "./speaking";
import { writingTask } from "@/stores/examEditor/sections";
import { WritingExercise } from "@/interfaces/exam";
const getResults = (results: any[], type: 'writing' | 'speaking') => {
return results.map((res) => {
if (type === 'writing') {
return {
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
} as WritingExercise;
}
return createSpeakingExercise(res);
});
};
const updates = (
results: any[],
sectionState: ExamPart,
sectionId: number,
currentModule: string,
levelGenerating: any[],
levelGenResults: any[],
type: 'writing' | 'speaking'
): Action[] => {
return [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [
...(sectionState as ExamPart).exercises,
...getResults(results, type)
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g =>
!results.flatMap(res => res.generating as Generating).includes(g)
)
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res =>
!results.flatMap(res => res.generating as Generating).includes(res.generating)
)
}
}
] as Action[];
};

View File

@@ -37,7 +37,7 @@ export function generate(
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", 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({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", 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 }
}); });
}; };

View File

@@ -12,16 +12,17 @@ interface Props {
genType: Generating; genType: Generating;
generateFnc: (sectionId: number) => void generateFnc: (sectionId: number) => void
className?: string; className?: string;
levelId?: number;
level?: boolean; level?: boolean;
} }
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, levelId }) => { 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 == levelId ? levelId : sectionId)); const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId));
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const generating = section?.generating; const generating = section?.generating;
const genResult = section?.genResult;
const levelGenerating = section?.levelGenerating; const levelGenerating = section?.levelGenerating;
const levelGenResults = section?.levelGenResults;
useEffect(()=> { useEffect(()=> {
const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType); 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); setLoading(gen);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, levelGenerating]) }, [generating, levelGenerating, levelGenResults, genResult])
if (section === undefined) return <></>; if (section === undefined) return <></>;
@@ -42,7 +43,7 @@ const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc,
className className
)} )}
disabled={loading} disabled={loading}
onClick={loading ? () => { } : () => generateFnc(levelId ? levelId : sectionId)} onClick={loading ? () => { } : () => generateFnc(sectionId)}
> >
{loading ? ( {loading ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center"> <div key={`section-${sectionId}`} className="flex items-center justify-center">

View File

@@ -3,6 +3,7 @@ import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import Dropdown from "./SettingsDropdown"; import Dropdown from "./SettingsDropdown";
import { LevelSectionSettings } from "@/stores/examEditor/types"; import { LevelSectionSettings } from "@/stores/examEditor/types";
import { LevelPart } from '@/interfaces/exam';
interface Props { interface Props {
module: Module; module: Module;
@@ -24,6 +25,8 @@ const SectionPicker: React.FC<Props> = ({
state.modules["level"].sections.find((s) => s.sectionId === sectionId) state.modules["level"].sections.find((s) => s.sectionId === sectionId)
); );
const state = sectionState?.state as LevelPart;
if (sectionState === undefined) return null; if (sectionState === undefined) return null;
const { readingSection, listeningSection } = sectionState; const { readingSection, listeningSection } = sectionState;
@@ -34,7 +37,34 @@ const SectionPicker: React.FC<Props> = ({
const handleSectionChange = (value: number) => { const handleSectionChange = (value: number) => {
const newValue = currentValue === value ? undefined : value; const newValue = currentValue === value ? undefined : value;
setSelectedValue(newValue); 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_STATE",
payload: {
sectionId,
module: "level",
update: {
...state,
...update
}
}
})
setTimeout(() => {
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { payload: {
@@ -44,6 +74,7 @@ const SectionPicker: React.FC<Props> = ({
value: newValue value: newValue
} }
}); });
}, 500);
}; };
const getTitle = () => { const getTitle = () => {
@@ -81,7 +112,7 @@ const SectionPicker: React.FC<Props> = ({
<input <input
type="checkbox" type="checkbox"
checked={currentValue === num} checked={currentValue === num}
onChange={() => {}} onChange={() => { }}
className={` className={`
h-5 w-5 cursor-pointer h-5 w-5 cursor-pointer
accent-ielts-${module} accent-ielts-${module}

View File

@@ -55,22 +55,147 @@ const LevelSettings: React.FC = () => {
const readingSection = section.readingSection; const readingSection = section.readingSection;
const listeningSection = section.listeningSection; 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 === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; 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 = { const exam: LevelExam = {
parts: sections.map((s) => { parts: sections.map((s) => {
const part = s.state as LevelPart; 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 { return {
...part, ...part,
intro: localSettings.currentIntro, audio: part.audio ? {
category: localSettings.category ...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, isDiagnostic: false,
minTimer, minTimer,
module: "level", module: "level",
@@ -79,23 +204,31 @@ const LevelSettings: React.FC = () => {
private: isPrivate, private: isPrivate,
}; };
axios.post(`/api/exam/level`, exam) const result = await axios.post('/api/exam/level', exam);
.then((result) => {
playSound("sent"); playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`); toast.success(`Submitted Exam ID: ${result.data.id}`);
})
.catch((error) => { Array.from(audioMap.values()).forEach(url => {
console.log(error); URL.revokeObjectURL(url);
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); });
}) 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 = () => { const preview = () => {
setExam({ setExam({
parts: sections.map((s) => { parts: sections.map((s) => {
const exercise = s.state as LevelPart; const part = s.state as LevelPart;
return { return {
...exercise, ...part,
intro: s.settings.currentIntro, intro: s.settings.currentIntro,
category: s.settings.category 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; const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises.find((ex) => ex.id === focusedExercise.id) as SpeakingExercise | InteractiveSpeakingExercise;
return ( return (
<SettingsEditor <SettingsEditor
sectionLabel={`Part ${focusedSection}`} sectionLabel={`Part ${focusedSection}`}
@@ -123,8 +255,8 @@ const LevelSettings: React.FC = () => {
module="level" module="level"
introPresets={[]} introPresets={[]}
preview={preview} preview={preview}
canPreview={canPreview} canPreview={canPreviewOrSubmit}
canSubmit={canPreview} canSubmit={canPreviewOrSubmit}
submitModule={submitLevel} submitModule={submitLevel}
> >
<div> <div>
@@ -211,7 +343,6 @@ const LevelSettings: React.FC = () => {
/> />
</Dropdown> </Dropdown>
</div > </div >
{/*
<div> <div>
<Dropdown title="Add Speaking Exercises" className={ <Dropdown title="Add Speaking Exercises" className={
clsx( clsx(
@@ -225,19 +356,19 @@ const LevelSettings: React.FC = () => {
open={localSettings.isSpeakingDropdownOpen} open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
> >
<div className="space-y-2 px-2 pb-2">
<Dropdown title="Exercises" className={ <Dropdown title="Exercises" className={
clsx( clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border", "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", "bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300", "text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg" 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"} 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} open={localSettings.isSpeakingExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingExercisesOpen: isOpen }, false)}
> >
<div className="space-y-2 px-2 pb-2">
<ExercisePicker <ExercisePicker
module="speaking" module="speaking"
sectionId={focusedSection} sectionId={focusedSection}
@@ -245,31 +376,33 @@ const LevelSettings: React.FC = () => {
levelSectionId={focusedSection} levelSectionId={focusedSection}
level level
/> />
</div>
</Dropdown> </Dropdown>
{speakingExercise !== undefined && {speakingExercise !== undefined &&
<Dropdown title={`Configure Speaking Exercise ${focusedExercise?.questionId}`} className={ <Dropdown title={`Configure Speaking Exercise #${Number(focusedExercise!.questionId) + 1}`} className={
clsx( clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border", "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", "bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300", "text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg" 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"} 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} open={localSettings.isConfigureExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isConfigureExercisesOpen: isOpen }, false)}
> >
<div className="space-y-2 px-2 pb-2">
<SpeakingComponents <SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise }} {...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise, id: speakingExercise.id, sectionId: focusedSection }}
level level
/> />
</div>
</Dropdown> </Dropdown>
} }
</div>
</Dropdown> </Dropdown>
</div> </div>
*/}
</SettingsEditor > </SettingsEditor >
); );
}; };

View File

@@ -184,20 +184,17 @@ const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, u
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0} disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
contentWrapperClassName={level ? `border border-ielts-listening` : ''} contentWrapperClassName={level ? `border border-ielts-listening` : ''}
> >
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4"> <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-l-lg border border-r-0 border-gray-300"> <span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
Generate audio recording for this section Generate audio recording for this section
</span> </span>
<div className="-ml-2.5">
<GenerateBtn <GenerateBtn
module="listening" module="listening"
genType="audio" genType="audio"
sectionId={levelId ? levelId : focusedSection} sectionId={levelId ? levelId : focusedSection}
generateFnc={generateAudio} generateFnc={generateAudio}
levelId={focusedSection}
/> />
</div> </div>
</div>
</Dropdown> </Dropdown>
</> </>
); );

View File

@@ -1,6 +1,6 @@
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types"; import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { generate } from "../Shared/Generate"; import { generate } from "../Shared/Generate";
import Dropdown from "../Shared/SettingsDropdown"; import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
@@ -11,6 +11,7 @@ import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/inte
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { generateVideos } from "../Shared/generateVideos"; import { generateVideos } from "../Shared/generateVideos";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import useCanGenerate from "./useCanGenerate";
export interface Avatar { export interface Avatar {
name: string; name: string;
@@ -23,16 +24,19 @@ interface Props {
section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart; section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart;
level?: boolean; level?: boolean;
module?: Module; 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 { currentModule, speakingAvatars, dispatch, modules } = useExamEditorStore();
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule]) 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 [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
const generateScript = useCallback((sectionId: number) => { const generateScript = useCallback((scriptSectionId: number) => {
const queryParams: { const queryParams: {
difficulty: string; difficulty: string;
first_topic?: string; first_topic?: string;
@@ -40,7 +44,7 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
topic?: string; topic?: string;
} = { difficulty }; } = { difficulty };
if (sectionId === 1) { if (scriptSectionId === 1) {
if (localSettings.speakingTopic) { if (localSettings.speakingTopic) {
queryParams['first_topic'] = localSettings.speakingTopic; queryParams['first_topic'] = localSettings.speakingTopic;
} }
@@ -52,17 +56,16 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
queryParams['topic'] = localSettings.speakingTopic; queryParams['topic'] = localSettings.speakingTopic;
} }
} }
generate( generate(
sectionId, level ? section.sectionId! : focusedSection,
currentModule, "speaking",
"speakingScript", `${id ? `${id}-` : ''}speakingScript`,
{ {
method: 'GET', method: 'GET',
queryParams queryParams
}, },
(data: any) => { (data: any) => {
switch (sectionId) { switch (level ? section.sectionId! : focusedSection) {
case 1: case 1:
return [{ return [{
prompts: data.questions, prompts: data.questions,
@@ -84,10 +87,12 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
default: default:
return [data]; 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) => { const onTopicChange = useCallback((speakingTopic: string) => {
updateLocalAndScheduleGlobal({ speakingTopic }); updateLocalAndScheduleGlobal({ speakingTopic });
@@ -97,39 +102,33 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
updateLocalAndScheduleGlobal({ speakingSecondTopic }); updateLocalAndScheduleGlobal({ speakingSecondTopic });
}, [updateLocalAndScheduleGlobal]); }, [updateLocalAndScheduleGlobal]);
const canGenerate = section && (() => { const canGenerate = useCanGenerate({
switch (focusedSection) { section,
case 1: { sections,
const currentSection = section as InteractiveSpeakingExercise; id,
return currentSection.first_title !== "" && focusedSection
currentSection.second_title !== "" && });
currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2;
useEffect(() => {
if (!canGenerate) {
updateLocalAndScheduleGlobal({ isGenerateVideoOpen: false }, false);
} }
case 2: { }, [canGenerate, updateLocalAndScheduleGlobal]);
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) => { 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( generateVideos(
section as InteractiveSpeakingExercise | SpeakingExercise, section as InteractiveSpeakingExercise | SpeakingExercise,
sectionId, level ? section.sectionId! : focusedSection,
selectedAvatar, selectedAvatar,
speakingAvatars speakingAvatars
).then((results) => { ).then((results) => {
switch (sectionId) { switch (level ? section.sectionId! : focusedSection) {
case 1: case 1:
case 3: { case 3: {
const interactiveSection = section as InteractiveSpeakingExercise; const interactiveSection = section as InteractiveSpeakingExercise;
@@ -137,22 +136,40 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
...prompt, ...prompt,
video_url: results[index].url || '' video_url: results[index].url || ''
})); }));
if (level) {
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: { type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: currentModule, field: "genResult", value: 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 } { generating: "video", result: [{ prompts: updatedPrompts }], module: module }
} }
}) })
}
break; break;
} }
case 2: { case 2: {
if (results[0]?.url) { if (results[0]?.url) {
if (level) {
dispatch({ dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: { type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: currentModule, field: "genResult", value: sectionId, field: "levelGenResults", value: [...state!.levelGenResults,
{ generating: "video", result: [{ video_url: results[0].url }], module: module } { 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; break;
} }
@@ -160,8 +177,10 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
}).catch((error) => { }).catch((error) => {
toast.error("Failed to generate the video, try again later!") 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 ( return (
<> <>
@@ -173,11 +192,11 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
contentWrapperClassName={level ? `border border-ielts-speaking` : ''} 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"> <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 <Input
key={`section-${focusedSection}`} key={`section-${secId}`}
type="text" type="text"
placeholder="Topic" placeholder="Topic"
name="category" name="category"
@@ -186,11 +205,11 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
value={localSettings.speakingTopic} value={localSettings.speakingTopic}
/> />
</div> </div>
{focusedSection === 1 && {secId === 1 &&
<div className="flex flex-col flex-grow gap-4 px-2"> <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> <label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
<Input <Input
key={`section-${focusedSection}`} key={`section-${secId}`}
type="text" type="text"
placeholder="Topic" placeholder="Topic"
name="category" name="category"
@@ -200,12 +219,13 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
/> />
</div> </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 <GenerateBtn
module="speaking" module="speaking"
genType="speakingScript" genType={`${id ? `${id}-` : ''}speakingScript`}
sectionId={focusedSection} sectionId={focusedSection}
generateFnc={generateScript} generateFnc={generateScript}
level={level}
/> />
</div> </div>
</div> </div>
@@ -216,6 +236,7 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
open={localSettings.isGenerateVideoOpen} open={localSettings.isGenerateVideoOpen}
disabled={!canGenerate} disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)} 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={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs"> <div className="relative flex-1 max-w-xs">
@@ -255,9 +276,10 @@ const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndSche
<GenerateBtn <GenerateBtn
module="speaking" module="speaking"
genType="video" genType={`${id ? `${id}-` : ''}video`}
sectionId={focusedSection} sectionId={focusedSection}
generateFnc={generateVideoCallback} generateFnc={generateVideoCallback}
level={level}
/> />
</div> </div>
</Dropdown> </Dropdown>

View File

@@ -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;

View File

@@ -35,7 +35,7 @@ export const listeningSection = (task: number) => {
}; };
export const speakingTask = (task: number) => { export const speakingTask = (task: number) => {
if (task === 3) { if ([1,3].includes(task)) {
return { return {
id: v4(), id: v4(),
type: "interactiveSpeaking", type: "interactiveSpeaking",

View File

@@ -72,6 +72,8 @@ export interface LevelSectionSettings extends SectionSettings {
speakingSecondTopic?: string; speakingSecondTopic?: string;
isSpeakingTopicOpen: boolean; isSpeakingTopicOpen: boolean;
isGenerateVideoOpen: boolean; isGenerateVideoOpen: boolean;
isSpeakingExercisesOpen: boolean;
isConfigureExercisesOpen: boolean;
// section picker // section picker
isReadingPickerOpen: boolean; isReadingPickerOpen: boolean;
@@ -80,6 +82,7 @@ export interface LevelSectionSettings extends SectionSettings {
export type Context = "passage" | "video" | "audio" | "listeningScript" | "speakingScript" | "writing"; export type Context = "passage" | "video" | "audio" | "listeningScript" | "speakingScript" | "writing";
export type Generating = Context | "exercises" | string | undefined; export type Generating = Context | "exercises" | string | undefined;
export type LevelGenResults = {generating: string, result: Record<string, any>[], module: Module};
export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise; export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
export type ExamPart = ListeningPart | ReadingPart | LevelPart; export type ExamPart = ListeningPart | ReadingPart | LevelPart;
@@ -91,7 +94,7 @@ export interface SectionState {
generating: Generating; generating: Generating;
genResult: {generating: string, result: Record<string, any>[], module: Module} | undefined; genResult: {generating: string, result: Record<string, any>[], module: Module} | undefined;
levelGenerating: Generating[]; levelGenerating: Generating[];
levelGenResults: {generating: string, result: Record<string, any>[], module: Module}[]; levelGenResults: LevelGenResults[];
focusedExercise?: {questionId: number; id: string} | undefined; focusedExercise?: {questionId: number; id: string} | undefined;
writingSection?: number; writingSection?: number;
speakingSection?: number; speakingSection?: number;