Reverted Level to only utas placement test exercises, Speaking, bug fixes, placeholder

This commit is contained in:
Carlos-Mesquita
2024-11-10 04:24:23 +00:00
parent c507eae507
commit 322d7905c3
39 changed files with 1251 additions and 279 deletions

View File

@@ -88,6 +88,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
}, },
onDiscard: () => { onDiscard: () => {
setAlerts([]);
setLocal(exercise); setLocal(exercise);
setEditing(false); setEditing(false);
}, },

View File

@@ -167,6 +167,8 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
}, },
onDiscard: () => { onDiscard: () => {
setEditing(false);
setAlerts([]);
setLocal(exercise); setLocal(exercise);
}, },
onMode: () => { onMode: () => {

View File

@@ -10,6 +10,8 @@ import useSectionEdit from "../../Hooks/useSectionEdit";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs"; import { BsFileText } from "react-icons/bs";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
import { RiVideoLine } from "react-icons/ri";
interface Props { interface Props {
sectionId: number; sectionId: number;
@@ -20,6 +22,8 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const [local, setLocal] = useState(exercise); const [local, setLocal] = useState(exercise);
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const { generating, genResult } = useExamEditorStore( const { generating, genResult } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
); );
@@ -86,6 +90,42 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
const isUnedited = local.prompts.length === 0; const isUnedited = local.prompts.length === 0;
useEffect(() => {
if (genResult && generating === "media") {
setLocal({ ...local, prompts: genResult[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "generating",
value: undefined
}
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "genResult",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
const handlePrevVideo = () => {
setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev));
};
const handleNextVideo = () => {
setCurrentVideoIndex((prev) =>
(prev < local.prompts.length - 1 ? prev + 1 : prev)
);
};
return ( return (
<> <>
<div className='relative pb-4'> <div className='relative pb-4'>
@@ -100,12 +140,65 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
module="speaking" module="speaking"
/> />
</div> </div>
{generating ? ( {generating && generating === "context" ? (
<GenLoader module={currentModule} /> <GenLoader module={currentModule} />
) : ( ) : (
<> <>
{editing ? ( {editing ? (
<> <>
{local.prompts.every((p) => p.video_url !== "") && (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4 w-full justify-between items-center">
<div className="flex flex-row gap-4">
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
<h3 className="font-semibold text-xl">Videos</h3>
</div>
<div className="flex items-center gap-4">
<button
onClick={handlePrevVideo}
disabled={currentVideoIndex === 0}
className={`p-2 rounded-full ${currentVideoIndex === 0
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-600">
{currentVideoIndex + 1} / {local.prompts.length}
</span>
<button
onClick={handleNextVideo}
disabled={currentVideoIndex === local.prompts.length - 1}
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-col gap-4 w-full items-center">
<div className="w-full">
<video
key={local.prompts[currentVideoIndex].video_url}
controls
className="w-full rounded-xl"
>
<source src={local.prompts[currentVideoIndex].video_url} />
</video>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{generating && generating === "media" &&
<GenLoader module={currentModule} custom="Generating the videos ... This may take a while ..." />
}
<Card> <Card>
<CardContent> <CardContent>
<div className="flex flex-col py-2 mt-2"> <div className="flex flex-col py-2 mt-2">

View File

@@ -8,6 +8,8 @@ import useSectionEdit from "../../Hooks/useSectionEdit";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs"; import { BsFileText } from "react-icons/bs";
import { RiVideoLine } from 'react-icons/ri';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
interface Props { interface Props {
sectionId: number; sectionId: number;
@@ -25,16 +27,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
return { ...exercise, prompts: defaultPrompts }; return { ...exercise, prompts: defaultPrompts };
}); });
const updateAvatarName = (avatarName: string) => { const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
setLocal(prev => {
const updatedPrompts = [...prev.prompts];
updatedPrompts[0] = {
...updatedPrompts[0],
text: updatedPrompts[0].text.replace("{avatar}", avatarName)
};
return { ...prev, prompts: updatedPrompts };
});
};
const { generating, genResult } = useExamEditorStore( const { generating, genResult } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
@@ -87,7 +80,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
} }
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]); }, [genResult, generating]);
const addPrompt = () => { const addPrompt = () => {
@@ -116,6 +109,44 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
const isUnedited = local.prompts.length === 2; const isUnedited = local.prompts.length === 2;
useEffect(() => {
if (genResult && generating === "media") {
console.log(genResult[0].prompts);
setLocal({ ...local, prompts: genResult[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "generating",
value: undefined
}
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "genResult",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
const handlePrevVideo = () => {
setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev));
};
const handleNextVideo = () => {
setCurrentVideoIndex((prev) =>
(prev < local.prompts.length - 1 ? prev + 1 : prev)
);
};
return ( return (
<> <>
<div className='relative pb-4'> <div className='relative pb-4'>
@@ -130,7 +161,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
module="speaking" module="speaking"
/> />
</div> </div>
{generating ? ( {generating && generating === "context" ? (
<GenLoader module={currentModule} /> <GenLoader module={currentModule} />
) : ( ) : (
<> <>
@@ -224,6 +255,59 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
</p> </p>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{local.prompts.every((p) => p.video_url !== "") && (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4 w-full justify-between items-center">
<div className="flex flex-row gap-4">
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
<h3 className="font-semibold text-xl">Videos</h3>
</div>
<div className="flex items-center gap-4">
<button
onClick={handlePrevVideo}
disabled={currentVideoIndex === 0}
className={`p-2 rounded-full ${currentVideoIndex === 0
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-600">
{currentVideoIndex + 1} / {local.prompts.length}
</span>
<button
onClick={handleNextVideo}
disabled={currentVideoIndex === local.prompts.length - 1}
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<FaChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-col gap-4 w-full items-center">
<div className="w-full">
<video
key={local.prompts[currentVideoIndex].video_url}
controls
className="w-full rounded-xl"
>
<source src={local.prompts[currentVideoIndex].video_url} />
</video>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{generating && generating === "media" &&
<GenLoader module={currentModule} custom="Generating the videos ... This may take a while ..." />
}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">

View File

@@ -11,12 +11,14 @@ import { BsFileText } from 'react-icons/bs';
import { AiOutlineUnorderedList } from 'react-icons/ai'; import { AiOutlineUnorderedList } from 'react-icons/ai';
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi"; import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
import GenLoader from "../Shared/GenLoader"; import GenLoader from "../Shared/GenLoader";
import { RiVideoLine } from 'react-icons/ri';
interface Props { interface Props {
sectionId: number; sectionId: number;
exercise: SpeakingExercise; exercise: SpeakingExercise;
} }
const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => { const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const [local, setLocal] = useState(exercise); const [local, setLocal] = useState(exercise);
@@ -25,18 +27,12 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
); );
const updateTopic = (topic: string) => {
setLocal(prev => ({ ...prev, topic: topic }));
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { topic: topic } } });
};
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
sectionId, sectionId,
mode: "edit", mode: "edit",
onSave: () => { onSave: () => {
setEditing(false); setEditing(false);
console.log(local);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
}, },
onDiscard: () => { onDiscard: () => {
@@ -69,6 +65,32 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]); }, [genResult, generating]);
useEffect(() => {
if (genResult && generating === "media") {
setLocal({...local, video_url: genResult[0].video_url});
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult[0].video_url} } });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "generating",
value: undefined
}
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "genResult",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
const addPrompt = () => { const addPrompt = () => {
setLocal(prev => ({ setLocal(prev => ({
...prev, ...prev,
@@ -92,8 +114,8 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
}; };
const isUnedited = local.text === "" || const isUnedited = local.text === "" ||
(local.title === undefined || local.title === "") || (local.title === undefined || local.title === "") ||
local.prompts.length === 0; local.prompts.length === 0;
const tooltipContent = ` const tooltipContent = `
<div class='p-2 max-w-xs'> <div class='p-2 max-w-xs'>
@@ -122,7 +144,7 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
module="speaking" module="speaking"
/> />
</div> </div>
{generating ? ( {generating && generating === "context" ? (
<GenLoader module={currentModule} /> <GenLoader module={currentModule} />
) : ( ) : (
<> <>
@@ -222,6 +244,25 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
</p> </p>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{local.video_url && <Card>
<CardContent className="pt-6">
<div className="flex flex-col items-start gap-3">
<div className="flex flex-row mb-3 gap-4">
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
<h3 className="font-semibold text-xl">Video</h3>
</div>
<div className="flex flex-col gap-4 w-full items-center">
<video controls className="w-full rounded-xl">
<source src={local.video_url } />
</video>
</div>
</div>
</CardContent>
</Card>
}
{generating && generating === "media" &&
<GenLoader module={currentModule} custom="Generating the video ... This may take a while ..." />
}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col items-start gap-3"> <div className="flex flex-col items-start gap-3">

View File

@@ -41,8 +41,9 @@ const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => {
useEffect(()=> { useEffect(()=> {
if (genResult !== undefined && generating === "context") { if (genResult !== undefined && generating === "context") {
setEditing(true); setEditing(true);
console.log(genResult);
setTitle(genResult[0].title); setTitle(genResult[0].title);
setContent(genResult[0].text) setContent(genResult[0].text);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "genResult", value: undefined}}) dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "genResult", value: undefined}})
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -131,7 +131,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId } })} onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId } })}
> >
{(currentModule === "level" && questions.ids?.length === 0) ? ( {(currentModule === "level" && questions.ids?.length === 0 && generating === undefined) ? (
background(<span className="flex justify-center">Generated exercises will appear here!</span>) background(<span className="flex justify-center">Generated exercises will appear here!</span>)
) : ( ) : (
expandedSections.includes(sectionId) && expandedSections.includes(sectionId) &&

View File

@@ -3,6 +3,7 @@ import ExerciseItem, { isExerciseItem } from "./types";
import ExerciseLabel from "../../Shared/ExerciseLabel"; import ExerciseLabel from "../../Shared/ExerciseLabel";
import MultipleChoice from "../../Exercises/MultipleChoice"; import MultipleChoice from "../../Exercises/MultipleChoice";
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice"; import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
import Passage from "../../Shared/Passage";
const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => { const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
@@ -14,6 +15,19 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
let firstWordId, lastWordId; let firstWordId, lastWordId;
switch (exercise.type) { switch (exercise.type) {
case "multipleChoice": case "multipleChoice":
let content = <MultipleChoice exercise={exercise} sectionId={sectionId} />;
const isReadingPassage = exercise.mcVariant && exercise.mcVariant === "passageUtas";
if (isReadingPassage) {
content = (<>
<div className="p-4">
<Passage
title={exercise.passage?.title!}
content={exercise.passage?.content!}
/>
</div>
<MultipleChoice exercise={exercise} sectionId={sectionId} /></>
);
}
firstWordId = exercise.questions[0].id; firstWordId = exercise.questions[0].id;
lastWordId = exercise.questions[exercise.questions.length - 1].id; lastWordId = exercise.questions[exercise.questions.length - 1].id;
return { return {
@@ -21,7 +35,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
label={`Multiple Choice Questions #${firstWordId} - #${lastWordId}`} label={isReadingPassage ? `Reading Passage: MC Questions #${firstWordId} - #${lastWordId}` : `Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
preview={ preview={
<> <>
&quot;{previewLabel(exercise.prompt)}...&quot; &quot;{previewLabel(exercise.prompt)}...&quot;
@@ -29,7 +43,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
} }
/> />
), ),
content: <MultipleChoice exercise={exercise} sectionId={sectionId} /> content
}; };
case "fillBlanks": case "fillBlanks":
firstWordId = exercise.solutions[0].id; firstWordId = exercise.solutions[0].id;
@@ -39,7 +53,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
sectionId, sectionId,
label: ( label: (
<ExerciseLabel <ExerciseLabel
label={`Fill Blanks Question #${firstWordId} - #${lastWordId}`} label={`Fill Blanks: MC Question #${firstWordId} - #${lastWordId}`}
preview={ preview={
<> <>
&quot;{previewLabel(exercise.prompt)}...&quot; &quot;{previewLabel(exercise.prompt)}...&quot;
@@ -54,7 +68,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
} }
}).filter(isExerciseItem); }).filter(isExerciseItem);
return items; return items;
}; };

View File

@@ -42,6 +42,8 @@ export function generate(
const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`;
console.log(config.body);
const request = config.method === 'POST' const request = config.method === 'POST'
? axios.post(url, config.body) ? axios.post(url, config.body)
: axios.get(url); : axios.get(url);

View File

@@ -14,7 +14,12 @@ interface Props {
} }
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => { const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => {
const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!; const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId));
if (section === undefined) return;
const {generating} = section;
const loading = generating && generating === genType; const loading = generating && generating === genType;
return ( return (
<button <button

View File

@@ -0,0 +1,125 @@
import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam";
import { Avatar } from "../speaking";
import axios from "axios";
interface VideoResponse {
status: 'STARTED' | 'ERROR' | 'COMPLETED' | 'IN_PROGRESS';
result: string;
}
interface VideoGeneration {
index: number;
text: string;
videoId?: string;
url?: string;
}
export async function generateVideos(section: InteractiveSpeakingExercise | SpeakingExercise, focusedSection: number, selectedAvatar: Avatar | null, speakingAvatars: Avatar[]) {
const abortController = new AbortController();
let activePollingIds: string[] = [];
const avatarToUse = selectedAvatar || speakingAvatars[Math.floor(Math.random() * speakingAvatars.length)];
const pollVideoGeneration = async (videoId: string): Promise<string> => {
while (true) {
try {
const { data } = await axios.get<VideoResponse>(`api/exam/media/poll?videoId=${videoId}`, {
signal: abortController.signal
});
if (data.status === 'ERROR') {
abortController.abort();
throw new Error('Video generation failed');
}
if (data.status === 'COMPLETED') {
const videoResponse = await axios.get(data.result, {
responseType: 'blob',
signal: abortController.signal
});
const videoUrl = URL.createObjectURL(
new Blob([videoResponse.data], { type: 'video/mp4' })
);
return videoUrl;
}
await new Promise(resolve => setTimeout(resolve, 10000)); // 10 secs
} catch (error: any) {
if (error.name === 'AbortError' || axios.isCancel(error)) {
throw new Error('Operation aborted');
}
throw error;
}
}
};
const generateSingleVideo = async (text: string, index: number): Promise<VideoGeneration> => {
try {
const { data } = await axios.post<VideoResponse>('/api/exam/media/speaking',
{ text, avatar: avatarToUse.name },
{
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal
}
);
if (data.status === 'ERROR') {
abortController.abort();
throw new Error('Initial video generation failed');
}
activePollingIds.push(data.result);
const videoUrl = await pollVideoGeneration(data.result);
return { index, text, videoId: data.result, url: videoUrl };
} catch (error) {
abortController.abort();
throw error;
}
};
try {
let videosToGenerate: { text: string; index: number }[] = [];
switch (focusedSection) {
case 1: {
const interactiveSection = section as InteractiveSpeakingExercise;
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
text: index === 0 ? prompt.text.replace("{avatar}", avatarToUse.name) : prompt.text,
index
}));
break;
}
case 2: {
const speakingSection = section as SpeakingExercise;
videosToGenerate = [{ text: `${speakingSection.text}. You have 1 minute to take notes.`, index: 0 }];
break;
}
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
text: prompt.text,
index
}));
break;
}
}
// Generate all videos concurrently
const results = await Promise.all(
videosToGenerate.map(({ text, index }) => generateSingleVideo(text, index))
);
// by order which they came in
return results.sort((a, b) => a.index - b.index);
} catch (error) {
// Clean up any ongoing requests
abortController.abort();
// Clean up any created URLs
activePollingIds.forEach(id => {
if (id) URL.revokeObjectURL(id);
});
throw error;
}
}

View File

@@ -80,6 +80,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
}); });
}, [updateLocalAndScheduleGlobal]); }, [updateLocalAndScheduleGlobal]);
return ( return (
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}> <div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}>
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div> <div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>

View File

@@ -7,15 +7,33 @@ import ExercisePicker from "../Shared/ExercisePicker";
import useExamEditorStore from "@/stores/examEditor"; import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState"; import useSettingsState from "../Hooks/useSettingsState";
import { SectionSettings } from "@/stores/examEditor/types"; import { SectionSettings } from "@/stores/examEditor/types";
import { toast } from "react-toastify";
import axios from "axios";
import { playSound } from "@/utils/sound";
import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/examStore";
import openDetachedTab from "@/utils/popout";
const LevelSettings: React.FC = () => { const LevelSettings: React.FC = () => {
const {currentModule } = useExamEditorStore(); const router = useRouter();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const {currentModule, title } = useExamEditorStore();
const { const {
focusedSection, focusedSection,
difficulty, difficulty,
sections, sections,
minTimer,
isPrivate,
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
@@ -23,45 +41,80 @@ const LevelSettings: React.FC = () => {
focusedSection focusedSection
); );
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as LevelPart; const section = sections.find((section) => section.sectionId == focusedSection);
if (section === undefined) return;
const defaultPresets: Option[] = [ const currentSection = section.state as LevelPart;
{
label: "Preset: Multiple Choice", const canPreview = currentSection.exercises.length > 0;
value: "Not available."
}, const submitLevel = () => {
{ if (title === "") {
label: "Preset: Multiple Choice - Blank Space", toast.error("Enter a title for the exam!");
value: "Welcome to {part} of the {label}. In this section, you'll be asked to select the correct word or group of words that best completes each sentence.\n\nFor each question, carefully read the sentence and click on the option (A, B, C, or D) that you believe is correct. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your previous answers, you can go back at any time by clicking \"Back\".", return;
},
{
label: "Preset: Multiple Choice - Underlined",
value: "Welcome to {part} of the {label}. In this section, you'll be asked to identify the underlined word or group of words that is not correct in each sentence.\n\nFor each question, carefully review the sentence and click on the option (A, B, C, or D) that you believe contains the incorrect word or group of words. After making your selection, you can proceed to the next question by clicking \"Next\". If needed, you can go back to previous questions by clicking \"Back\"."
},
{
label: "Preset: Blank Space",
value: "Not available."
},
{
label: "Preset: Reading Passage",
value: "Welcome to {part} of the {label}. In this section, you will read a text and answer the questions that follow.\n\nCarefully read the provided text, then select the correct answer (A, B, C, or D) for each question. After making your selection, you can proceed to the next question by clicking \"Next\". If you need to review or change your answers, you can go back at any time by clicking \"Back\"."
},
{
label: "Preset: Multiple Choice - Fill Blanks",
value: "Welcome to {part} of the {label}. In this section, you will read a text and choose the correct word to fill in each blank space.\n\nFor each question, carefully read the text and click on the option that you believe best fits the context."
} }
]; 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 preview = () => {
setExam({
parts: sections.map((s) => {
const exercise = s.state as LevelPart;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "level",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
} as LevelExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
openDetachedTab("popout?type=Exam&module=level", router)
}
return ( return (
<SettingsEditor <SettingsEditor
sectionLabel={`Part ${focusedSection}`} sectionLabel={`Part ${focusedSection}`}
sectionId={focusedSection} sectionId={focusedSection}
module="level" module="level"
introPresets={defaultPresets} introPresets={[]}
preview={()=>{}} preview={preview}
canPreview={false} canPreview={canPreview}
canSubmit={false} canSubmit={canPreview}
submitModule={()=> {}} submitModule={submitLevel}
> >
<div> <div>
<Dropdown title="Add Exercises" className={ <Dropdown title="Add Exercises" className={

View File

@@ -33,6 +33,7 @@ const ListeningSettings: React.FC = () => {
setExerciseIndex, setExerciseIndex,
setPartIndex, setPartIndex,
setQuestionIndex, setQuestionIndex,
setBgColor,
} = usePersistentExamStore(); } = usePersistentExamStore();
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
@@ -40,17 +41,25 @@ const ListeningSettings: React.FC = () => {
focusedSection focusedSection
); );
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as ListeningPart; const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ListeningPart;
const defaultPresets: Option[] = [ const defaultPresets: Option[] = [
{ {
label: "Preset: Writing Task 1", label: "Preset: Listening Section 1",
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task." value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions."
}, },
{ {
label: "Preset: Writing Task 2", label: "Preset: Listening Section 2",
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task." value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements."
} },
{
label: "Preset: Listening Section 3",
value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements."
},
{
label: "Preset: Listening Section 4",
value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject."
}
]; ];
@@ -123,8 +132,8 @@ const ListeningSettings: React.FC = () => {
...exercise.audio, ...exercise.audio,
source: index !== -1 ? urls[index] : exercise.audio.source source: index !== -1 ? urls[index] : exercise.audio.source
} : undefined, } : undefined,
intro: localSettings.currentIntro, intro: s.settings.currentIntro,
category: localSettings.category category: s.settings.category
}; };
}), }),
isDiagnostic: false, isDiagnostic: false,
@@ -159,8 +168,8 @@ const ListeningSettings: React.FC = () => {
const exercise = s.state as ListeningPart; const exercise = s.state as ListeningPart;
return { return {
...exercise, ...exercise,
intro: localSettings.currentIntro, intro: s.settings.currentIntro,
category: localSettings.category category: s.settings.category
}; };
}), }),
minTimer, minTimer,
@@ -174,6 +183,7 @@ const ListeningSettings: React.FC = () => {
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);
setPartIndex(0); setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=listening", router) openDetachedTab("popout?type=Exam&module=listening", router)
} }
@@ -226,7 +236,7 @@ const ListeningSettings: React.FC = () => {
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSection.script, dispatch]); }, [currentSection?.script, dispatch]);
const canPreview = sections.some( const canPreview = sections.some(
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0 (s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
@@ -283,7 +293,7 @@ const ListeningSettings: React.FC = () => {
module={currentModule} module={currentModule}
open={localSettings.isExerciseDropdownOpen} open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
disabled={currentSection.script === undefined && currentSection.audio === undefined} disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
> >
<ExercisePicker <ExercisePicker
module="listening" module="listening"
@@ -297,7 +307,7 @@ const ListeningSettings: React.FC = () => {
module={currentModule} module={currentModule}
open={localSettings.isAudioGenerationOpen} open={localSettings.isAudioGenerationOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
disabled={currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0} disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
center center
> >
<GenerateBtn <GenerateBtn

View File

@@ -25,6 +25,7 @@ const ReadingSettings: React.FC = () => {
setExerciseIndex, setExerciseIndex,
setPartIndex, setPartIndex,
setQuestionIndex, setQuestionIndex,
setBgColor,
} = usePersistentExamStore(); } = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore(); const { currentModule, title } = useExamEditorStore();
@@ -41,17 +42,22 @@ const ReadingSettings: React.FC = () => {
focusedSection focusedSection
); );
const currentSection = sections.find((section) => section.sectionId == focusedSection)!.state as ReadingPart; const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
const defaultPresets: Option[] = [ const defaultPresets: Option[] = [
{ {
label: "Preset: Writing Task 1", label: "Preset: Reading Passage 1",
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task." value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
}, },
{ {
label: "Preset: Writing Task 2", label: "Preset: Reading Passage 2",
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task." value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
} },
{
label: "Preset: Reading Passage 3",
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
}
]; ];
@@ -125,8 +131,8 @@ const ReadingSettings: React.FC = () => {
const exercise = s.state as ReadingPart; const exercise = s.state as ReadingPart;
return { return {
...exercise, ...exercise,
intro: localSettings.currentIntro, intro: s.settings.currentIntro,
category: localSettings.category category: s.settings.category
}; };
}), }),
minTimer, minTimer,
@@ -140,6 +146,7 @@ const ReadingSettings: React.FC = () => {
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);
setPartIndex(0); setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=reading", router) openDetachedTab("popout?type=Exam&module=reading", router)
} }
@@ -188,13 +195,13 @@ const ReadingSettings: React.FC = () => {
module={currentModule} module={currentModule}
open={localSettings.isExerciseDropdownOpen} open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
disabled={currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""} disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
> >
<ExercisePicker <ExercisePicker
module="reading" module="reading"
sectionId={focusedSection} sectionId={focusedSection}
difficulty={difficulty} difficulty={difficulty}
extraArgs={{ text: currentSection.text.content }} extraArgs={{ text: currentSection === undefined ? "" : currentSection.text.content }}
/> />
</Dropdown> </Dropdown>
</SettingsEditor> </SettingsEditor>

View File

@@ -2,34 +2,65 @@ import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState"; import useSettingsState from "../Hooks/useSettingsState";
import { SpeakingSectionSettings } from "@/stores/examEditor/types"; import { SpeakingSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { generate } from "./Shared/Generate"; import { generate } from "./Shared/Generate";
import SettingsEditor from "."; import SettingsEditor from ".";
import Dropdown from "./Shared/SettingsDropdown"; import Dropdown from "./Shared/SettingsDropdown";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import GenerateBtn from "./Shared/GenerateBtn"; import GenerateBtn from "./Shared/GenerateBtn";
import clsx from "clsx"; import clsx from "clsx";
import { FaFemale, FaMale, FaChevronDown } from "react-icons/fa";
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { generateVideos } from "./Shared/generateVideos";
import { usePersistentExamStore } from "@/stores/examStore";
import { useRouter } from "next/router";
import openDetachedTab from "@/utils/popout";
import axios from "axios";
import { playSound } from "@/utils/sound";
export interface Avatar {
name: string;
gender: string;
}
const SpeakingSettings: React.FC = () => { const SpeakingSettings: React.FC = () => {
const { currentModule } = useExamEditorStore(); const router = useRouter();
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
const {
setExam,
setExerciseIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { title, currentModule, speakingAvatars, dispatch } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
currentModule, currentModule,
focusedSection, focusedSection,
); );
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
const defaultPresets: Option[] = [ const defaultPresets: Option[] = [
{ {
label: "Preset: Writing Task 1", label: "Preset: Speaking Part 1",
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task." value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
}, },
{ {
label: "Preset: Writing Task 2", label: "Preset: Speaking Part 2",
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task." value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
} },
{
label: "Preset: Speaking Part 3",
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
}
]; ];
const generateScript = useCallback((sectionId: number) => { const generateScript = useCallback((sectionId: number) => {
@@ -86,7 +117,7 @@ const SpeakingSettings: React.FC = () => {
} }
} }
); );
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings, difficulty]); }, [localSettings, difficulty]);
const onTopicChange = useCallback((topic: string) => { const onTopicChange = useCallback((topic: string) => {
@@ -97,16 +128,256 @@ const SpeakingSettings: React.FC = () => {
updateLocalAndScheduleGlobal({ secondTopic: topic }); updateLocalAndScheduleGlobal({ secondTopic: topic });
}, [updateLocalAndScheduleGlobal]); }, [updateLocalAndScheduleGlobal]);
const canPreviewOrSubmit = (() => {
return sections.every((s) => {
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
switch (section.type) {
case 'speaking':
return section.title !== '' &&
section.text !== '' &&
section.video_url !== '' &&
section.prompts.every(prompt => prompt !== '');
case 'interactiveSpeaking':
if ('first_title' in section && 'second_title' in section) {
return section.first_title !== '' &&
section.second_title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '') &&
section.prompts.length > 2;
}
return section.title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '');
default:
return false;
}
});
})();
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 generateVideoCallback = useCallback((sectionId: number) => {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "media" } })
generateVideos(
section as InteractiveSpeakingExercise | SpeakingExercise,
sectionId,
selectedAvatar,
speakingAvatars
).then((results) => {
switch (sectionId) {
case 1:
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
...prompt,
video_url: results[index].url || ''
}));
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ prompts: updatedPrompts }] } })
break;
}
case 2: {
if (results[0]?.url) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ video_url: results[0].url }] } })
}
break;
}
}
}).catch((error) => {
toast.error("Failed to generate the video, try again later!")
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAvatar, section]);
const submitSpeaking = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
const formData = new FormData();
const urlMap = new Map<string, string>();
const sectionsWithVideos = sections.filter(s => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.some(prompt => prompt.video_url !== "");
}
return false;
});
if (sectionsWithVideos.length === 0) {
toast.error('No video sections found in the exam! Please record or import videos.');
return;
}
await Promise.all(
sectionsWithVideos.map(async (section) => {
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}`, exercise.video_url);
} else {
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();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'speaking_videos'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { urls } = response.data;
const exam: SpeakingExam = {
exercises: sections.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}`);
return {
...exercise,
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
intro: s.settings.currentIntro,
category: s.settings.category
};
} else {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts,
intro: s.settings.currentIntro,
category: s.settings.category
};
}
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
instructorGender: "varied",
private: isPrivate,
};
const result = await axios.post('/api/exam/speaking', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
Array.from(urlMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
exercises: sections
.filter((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.every(prompt => prompt.video_url !== "");
}
return false;
})
.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
} as SpeakingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=speaking", router)
}
return ( return (
<SettingsEditor <SettingsEditor
sectionLabel={`Speaking ${focusedSection}`} sectionLabel={`Speaking ${focusedSection}`}
sectionId={focusedSection} sectionId={focusedSection}
module="speaking" module="speaking"
introPresets={[defaultPresets[focusedSection - 1]]} introPresets={[defaultPresets[focusedSection - 1]]}
preview={() => { }} preview={preview}
canPreview={false} canPreview={canPreviewOrSubmit}
canSubmit={false} canSubmit={canPreviewOrSubmit}
submitModule={()=> {}} submitModule={submitSpeaking}
> >
<Dropdown <Dropdown
title="Generate Script" title="Generate Script"
@@ -115,7 +386,7 @@ const SpeakingSettings: React.FC = () => {
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)} setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
> >
<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", focusedSection === 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">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
<Input <Input
@@ -155,19 +426,52 @@ const SpeakingSettings: React.FC = () => {
<Dropdown <Dropdown
title="Generate Video" title="Generate Video"
module={currentModule} module={currentModule}
open={localSettings.isGenerateAudio} open={localSettings.isGenerateAudioOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudio: isOpen }, false)} disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)}
> >
<div className={clsx("gap-2 px-2 pb-4 flex flex-row items-center" )}> <div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs">
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}> <select
<GenerateBtn value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
module={currentModule} onChange={(e) => {
genType="media" if (e.target.value === "") {
sectionId={focusedSection} setSelectedAvatar(null);
generateFnc={generateScript} } else {
/> const [name, gender] = e.target.value.split("-");
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
if (avatar) setSelectedAvatar(avatar);
}
}}
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">Select an avatar</option>
{speakingAvatars.map((avatar) => (
<option
key={`${avatar.name}-${avatar.gender}`}
value={`${avatar.name}-${avatar.gender}`}
>
{avatar.name}
</option>
))}
</select>
<div className="absolute right-2.5 top-2.5 pointer-events-none">
{selectedAvatar && (
selectedAvatar.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)
)}
</div>
</div> </div>
<GenerateBtn
module={currentModule}
genType="media"
sectionId={focusedSection}
generateFnc={generateVideoCallback}
/>
</div> </div>
</Dropdown> </Dropdown>
</SettingsEditor> </SettingsEditor>

View File

@@ -123,18 +123,46 @@ const ExerciseWizard: React.FC<Props> = ({
color={!currentValue ? `#F3F4F6` : `#1F2937`} color={!currentValue ? `#F3F4F6` : `#1F2937`}
/> />
<Tooltip id={`${exerciseIndex}`} className="z-50 bg-white shadow-md rounded-sm" /> <Tooltip id={`${exerciseIndex}`} className="z-50 bg-white shadow-md rounded-sm" />
<a data-tooltip-id={`${exerciseIndex}`} data-tooltip-html="Generate or use placeholder?" className='ml-1 flex items-center justify-center'> <a data-tooltip-id={`${exerciseIndex}`} data-tooltip-html="Generate or use placeholder?" className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt={"AI Generated?"} /> <Image src="/mat-icon-info.svg" width={24} height={24} alt={"AI Generated?"} />
</a> </a>
</div>
);
}
if ('type' in param && param.type === 'text') {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-white">
{param.label}
</label>
{param.tooltip && (
<>
<Tooltip id={config.type} className="z-50 bg-white shadow-md rounded-sm" />
<a data-tooltip-id={config.type} data-tooltip-html={param.tooltip} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt={param.tooltip} />
</a>
</>
)}
</div>
<input
type="text"
value={config.params[param.param || ''] as string}
onChange={(e) => handleParameterChange(
exerciseIndex,
param.param || '',
e.target.value
)}
className="px-3 py-2 shadow-lg rounded-md text-mti-gray-dim w-full"
placeholder="Enter here..."
/>
</div> </div>
); );
} }
const inputValue = Number(config.params[param.param || '1'].toString()); const inputValue = Number(config.params[param.param || '1'].toString());
const isParagraphMatch = config.type.split("?name=")[1] === "paragraphMatch"; const isParagraphMatch = config.type.split("?name=")[1] === "paragraphMatch";
const maxParagraphs = isParagraphMatch ? extraArgs!.text.split("\n\n").length : 50; const maxParagraphs = isParagraphMatch ? extraArgs!.text.split("\n\n").length : 50;
@@ -183,7 +211,8 @@ const ExerciseWizard: React.FC<Props> = ({
<exercise.icon className="h-5 w-5" /> <exercise.icon className="h-5 w-5" />
<h3 className="font-medium text-lg">{exercise.label}</h3> <h3 className="font-medium text-lg">{exercise.label}</h3>
</div> </div>
{generateParam && renderParameterInput(generateParam, exerciseIndex, config)} {/* when placeholders are done uncomment this*/}
{/*generateParam && renderParameterInput(generateParam, exerciseIndex, config)*/}
</div> </div>
); );
}; };

View File

@@ -209,21 +209,29 @@ const listening = (section: number) => {
} }
const EXERCISES: ExerciseGen[] = [ const EXERCISES: ExerciseGen[] = [
{ /*{
label: "Multiple Choice", label: "Multiple Choice",
type: "multipleChoice", type: "multipleChoice",
icon: FaListUl, icon: FaListUl,
extra: [ extra: [
{
param: "name",
value: "multipleChoice"
},
quantity(10, "Amount"), quantity(10, "Amount"),
generate() generate()
], ],
module: "level" module: "level"
}, },*/
{ {
label: "Multiple Choice - Blank Space", label: "Multiple Choice - Blank Space",
type: "mcBlank", type: "mcBlank",
icon: FaEdit, icon: FaEdit,
extra: [ extra: [
{
param: "name",
value: "mcBlank"
},
quantity(10, "Amount"), quantity(10, "Amount"),
generate() generate()
], ],
@@ -234,6 +242,10 @@ const EXERCISES: ExerciseGen[] = [
type: "mcUnderline", type: "mcUnderline",
icon: FaUnderline, icon: FaUnderline,
extra: [ extra: [
{
param: "name",
value: "mcUnderline"
},
quantity(10, "Amount"), quantity(10, "Amount"),
generate() generate()
], ],
@@ -255,10 +267,14 @@ const EXERCISES: ExerciseGen[] = [
module: "level" module: "level"
},*/ },*/
{ {
label: "Fill Blanks: MC", label: "Fill Blanks: Multiple Choice",
type: "fillBlanksMC", type: "fillBlanksMC",
icon: FaPen, icon: FaPen,
extra: [ extra: [
{
param: "name",
value: "fillBlanksMC"
},
quantity(10, "Nº of Blanks"), quantity(10, "Nº of Blanks"),
{ {
label: "Passage Word Size", label: "Passage Word Size",
@@ -270,25 +286,26 @@ const EXERCISES: ExerciseGen[] = [
module: "level" module: "level"
}, },
{ {
label: "Reading Passage", label: "Reading Passage: Multiple Choice",
type: "passageUtas", type: "passageUtas",
icon: FaBookOpen, icon: FaBookOpen,
extra: [ extra: [
{
param: "name",
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"),
label: "Multiple Choice Quantity",
param: "mc_qty",
value: "10"
},
{ {
label: "Reading Passage Topic", label: "Reading Passage Topic",
param: "topic", param: "topic",
value: "" value: "",
type: "text"
}, },
{ {
label: "Passage Word Size", label: "Passage Word Size",

View File

@@ -17,6 +17,6 @@ export interface ExerciseGen {
type: string; type: string;
icon: IconType; icon: IconType;
sectionId?: number; sectionId?: number;
extra?: { param?: string; value?: string | number | boolean; label?: string; tooltip?: string}[]; extra?: { param?: string; value?: string | number | boolean; label?: string; tooltip?: string, type?: string}[];
module: string module: string
} }

View File

@@ -23,13 +23,15 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
extraArgs = undefined, extraArgs = undefined,
}) => { }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const { difficulty} = useExamEditorStore((store) => store.modules[currentModule]); const { difficulty } = useExamEditorStore((store) => store.modules[currentModule]);
const section = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId == sectionId)!); const section = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId == sectionId));
const { state, selectedExercises } = section;
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
if (section === undefined) return;
const { state, selectedExercises } = section;
const getFullExerciseType = (exercise: ExerciseGen): string => { const getFullExerciseType = (exercise: ExerciseGen): string => {
if (exercise.extra && exercise.extra.length > 0) { if (exercise.extra && exercise.extra.length > 0) {
const extraValue = exercise.extra.find(e => e.param === 'name')?.value; const extraValue = exercise.extra.find(e => e.param === 'name')?.value;
@@ -48,7 +50,7 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "selectedExercises", value: newSelected } }) dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "selectedExercises", value: newSelected } })
}; };
const moduleExercises = module === 'level' ? EXERCISES : (sectionId ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module)); const moduleExercises = (sectionId && module !== "level" ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module));
const onModuleSpecific = (configurations: ExerciseConfig[]) => { const onModuleSpecific = (configurations: ExerciseConfig[]) => {
const exercises = configurations.map(config => { const exercises = configurations.map(config => {
@@ -119,7 +121,15 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
return ( return (
<> <>
<Modal isOpen={pickerOpen} onClose={() => setPickerOpen(false)} title="Exercise Wizard"> <Modal isOpen={pickerOpen} onClose={() => setPickerOpen(false)} title="Exercise Wizard"
titleClassName={clsx(
"text-2xl font-semibold text-center py-4",
`bg-ielts-${module} text-white`,
"shadow-sm",
"-mx-6 -mt-6",
"mb-6"
)}
>
<ExerciseWizard <ExerciseWizard
sectionId={sectionId} sectionId={sectionId}
exercises={moduleExercises} exercises={moduleExercises}
@@ -165,8 +175,8 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
> >
{section.generating === "exercises" ? ( {section.generating === "exercises" ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center"> <div key={`section-${sectionId}`} className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} /> <BsArrowRepeat className="text-white animate-spin" size={25} />
</div> </div>
) : ( ) : (
<>Set Up Exercises ({selectedExercises.length}) </> <>Set Up Exercises ({selectedExercises.length}) </>
)} )}

View File

@@ -6,7 +6,7 @@ import { capitalize } from 'lodash';
import { Module } from '@/interfaces'; import { Module } from '@/interfaces';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import useExamEditorStore from '@/stores/examEditor'; import useExamEditorStore from '@/stores/examEditor';
import { ReadingPart } from '@/interfaces/exam'; import { LevelPart, ReadingPart } from '@/interfaces/exam';
import { defaultSectionSettings } from '@/stores/examEditor/defaults'; import { defaultSectionSettings } from '@/stores/examEditor/defaults';
const WordUploader: React.FC<{ module: Module }> = ({ module }) => { const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
@@ -72,7 +72,7 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
setShowUploaders(false); setShowUploaders(false);
switch (currentModule) { switch (currentModule) {
case 'reading': case 'reading': {
const newSectionsStates = data.parts.map( const newSectionsStates = data.parts.map(
(part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part) (part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part)
); );
@@ -88,6 +88,28 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
} }
}); });
break; break;
}
case 'level': {
const newSectionsStates = data.parts.map(
(part: LevelPart, index: number) => defaultSectionSettings(module, index + 1, part)
);
dispatch({
type: "UPDATE_MODULE", payload: {
updates: {
sections: newSectionsStates,
minTimer: data.minTimer,
importModule: false,
importing: false,
sectionLabels: Array.from({ length: newSectionsStates.length }, (_, index) => ({
id: index + 1,
label: `Part ${index + 1}`
}))
},
module
}
});
break;
}
} }
} catch (error) { } catch (error) {
toast.error(`An unknown error has occured while import ${module} exam!`); toast.error(`An unknown error has occured while import ${module} exam!`);

View File

@@ -1,40 +1,49 @@
import { useState } from "react";
import Dropdown from "@/components/Dropdown"; import Dropdown from "@/components/Dropdown";
import clsx from "clsx"; import clsx from "clsx";
interface Props { interface Props {
title: string; title: string;
content: string; content: string;
open: boolean; open?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen?: React.Dispatch<React.SetStateAction<boolean>>;
} }
const Passage: React.FC<Props> = ({ title, content, open, setIsOpen}) => { const Passage: React.FC<Props> = ({ title, content, open: externalOpen, setIsOpen: externalSetIsOpen }) => {
const paragraphs = content.split('\n\n'); const [internalOpen, setInternalOpen] = useState(false);
return ( const isOpen = externalOpen ?? internalOpen;
<Dropdown const setIsOpen = externalSetIsOpen ?? setInternalOpen;
title={title}
const paragraphs = content.split('\n\n');
return (
<Dropdown
title={title}
className={clsx(
"bg-white p-6 w-full items-center",
isOpen ? "rounded-t-lg border-b border-gray-200" : "rounded-lg shadow-lg"
)}
titleClassName="text-2xl font-semibold text-gray-800"
contentWrapperClassName="p-6 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"
open={isOpen}
setIsOpen={setIsOpen}
>
<div>
{paragraphs.map((paragraph, index) => (
<p
key={index}
className={clsx( className={clsx(
"bg-white p-6 w-full items-center", "text-justify",
open ? "rounded-t-lg border-b border-gray-200" : "rounded-lg shadow-lg" index < paragraphs.length - 1 ? 'mb-4' : 'mb-6'
)} )}
titleClassName="text-2xl font-semibold text-gray-800" >
contentWrapperClassName="p-6 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out" {paragraph.trim()}
open={open} </p>
setIsOpen={setIsOpen} ))}
> </div>
<div> </Dropdown>
{paragraphs.map((paragraph, index) => ( );
<p
key={index}
className={clsx("text-justify", index < paragraphs.length - 1 ? 'mb-4' : 'mb-6')}
>
{paragraph.trim()}
</p>
))}
</div>
</Dropdown>
);
} }
export default Passage; export default Passage;

View File

@@ -153,7 +153,7 @@ const ExamEditor: React.FC = () => {
) : ( ) : (
<div className="flex flex-col gap-3 w-1/3"> <div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label> <label className="font-normal text-base text-mti-gray-dim">Number of Parts</label>
<Input type="number" name="Number of Parts" onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} /> <Input type="number" name="Number of Parts" min={1} onChange={(v) => setNumberOfParts(parseInt(v))} value={numberOfParts} />
</div> </div>
)} )}
<div className="flex flex-col gap-3 w-fit h-fit"> <div className="flex flex-col gap-3 w-fit h-fit">
@@ -171,7 +171,7 @@ const ExamEditor: React.FC = () => {
name="label" name="label"
onChange={(text) => updateModule({ examLabel: text })} onChange={(text) => updateModule({ examLabel: text })}
roundness="xl" roundness="xl"
defaultValue={examLabel} value={examLabel}
required required
/> />
</div> </div>

View File

@@ -24,6 +24,7 @@ export default function InteractiveSpeaking({
userSolutions, userSolutions,
onNext, onNext,
onBack, onBack,
preview = false
}: InteractiveSpeakingExercise & CommonProps) { }: InteractiveSpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
@@ -298,9 +299,15 @@ export default function InteractiveSpeaking({
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full"> <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full"> {preview ? (
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"} {questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button> </Button>
) : (
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,20 +1,20 @@
import {SpeakingExercise} from "@/interfaces/exam"; import { SpeakingExercise } from "@/interfaces/exam";
import {CommonProps} from "."; import { CommonProps } from ".";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation"; import { downloadBlob } from "@/utils/evaluation";
import axios from "axios"; import axios from "axios";
import Modal from "../Modal"; import Modal from "../Modal";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), { ssr: false });
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
@@ -28,7 +28,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
const saveToStorage = async () => { const saveToStorage = async () => {
if (mediaBlob && mediaBlob.startsWith("blob")) { if (mediaBlob && mediaBlob.startsWith("blob")) {
const blobBuffer = await downloadBlob(mediaBlob); const blobBuffer = await downloadBlob(mediaBlob);
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" });
const seed = Math.random().toString().replace("0.", ""); const seed = Math.random().toString().replace("0.", "");
@@ -42,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
}, },
}; };
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
return response.data.path; return response.data.path;
} }
@@ -52,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
useEffect(() => { useEffect(() => {
if (userSolutions.length > 0) { if (userSolutions.length > 0) {
const {solution} = userSolutions[0] as {solution?: string}; const { solution } = userSolutions[0] as { solution?: string };
if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution);
} }
@@ -79,8 +79,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
const next = async () => { const next = async () => {
onNext({ onNext({
exercise: id, exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: {correct: 0, total: 100, missing: 0}, score: { correct: 0, total: 100, missing: 0 },
type, type,
}); });
}; };
@@ -88,8 +88,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
const back = async () => { const back = async () => {
onBack({ onBack({
exercise: id, exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
score: {correct: 0, total: 100, missing: 0}, score: { correct: 0, total: 100, missing: 0 },
type, type,
}); });
}; };
@@ -115,6 +115,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
} }
}; };
console.log(preview);
return ( return (
<div className="flex flex-col gap-4 mt-4 w-full"> <div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex justify-between w-full gap-8"> <div className="flex justify-between w-full gap-8">
@@ -189,7 +191,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
<ReactMediaRecorder <ReactMediaRecorder
audio audio
onStop={(blob) => setMediaBlob(blob)} onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center"> <div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <div className="flex gap-8 items-center justify-center py-8">
@@ -307,9 +309,15 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full"> <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full"> {preview ? (
Next <Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
</Button> Next
</Button>
) : (
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
Next
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ interface Props {
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
max?: number; max?: number;
min?: number;
name: string; name: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -28,6 +29,7 @@ export default function Input({
className, className,
roundness = "full", roundness = "full",
disabled = false, disabled = false,
min,
onChange, onChange,
}: Props) { }: Props) {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -90,7 +92,7 @@ export default function Input({
value={value} value={value}
max={max} max={max}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
min={type === "number" ? 0 : undefined} min={type === "number" ? (min ?? 0) : undefined}
placeholder={placeholder} placeholder={placeholder}
className={clsx( className={clsx(
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", "px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",

View File

@@ -1,9 +1,10 @@
import Level from "@/exams/Level"; import Level from "@/exams/Level";
import Listening from "@/exams/Listening"; import Listening from "@/exams/Listening";
import Reading from "@/exams/Reading"; import Reading from "@/exams/Reading";
import Speaking from "@/exams/Speaking";
import Writing from "@/exams/Writing"; import Writing from "@/exams/Writing";
import { usePersistentStorage } from "@/hooks/usePersistentStorage"; import { usePersistentStorage } from "@/hooks/usePersistentStorage";
import { LevelExam, ListeningExam, ReadingExam, WritingExam } from "@/interfaces/exam"; import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { usePersistentExamStore } from "@/stores/examStore"; import { usePersistentExamStore } from "@/stores/examStore";
import clsx from "clsx"; import clsx from "clsx";
@@ -21,7 +22,7 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
state.setPartIndex(0); state.setPartIndex(0);
state.setExerciseIndex(0); state.setExerciseIndex(0);
state.setQuestionIndex(0); state.setQuestionIndex(0);
}} showSolutions={true} preview={true} /> }} preview={true} />
} }
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 && {state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 &&
<Writing exam={state.exam as WritingExam} onFinish={() => { <Writing exam={state.exam as WritingExam} onFinish={() => {
@@ -42,6 +43,11 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
state.setQuestionIndex(0); state.setQuestionIndex(0);
}} preview={true} /> }} preview={true} />
} }
{state.exam?.module == "speaking" && state.exam.exercises.length > 0 &&
<Speaking exam={state.exam as SpeakingExam} onFinish={() => {
state.setExerciseIndex(-1);
}} preview={true} />
}
</div> </div>
</div> </div>
); );

View File

@@ -75,6 +75,15 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions); const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const [startNow, setStartNow] = useState<boolean>(!showSolutions); const [startNow, setStartNow] = useState<boolean>(!showSolutions);
useEffect(() => {
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
setShowPartDivider(true);
setBgColor(levelBgColor);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
useEffect(() => { useEffect(() => {
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) { if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
setCurrentExercise(exam.parts[0].exercises[0]); setCurrentExercise(exam.parts[0].exercises[0]);
@@ -462,7 +471,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} /> <QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
{ {
!(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) && !(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) &&
<Timer minTimer={exam.minTimer} disableTimer={showSolutions} standalone={true} /> <Timer minTimer={exam.minTimer} disableTimer={showSolutions || preview} standalone={true} />
} }
{(showPartDivider || startNow) ? {(showPartDivider || startNow) ?
<PartDivider <PartDivider
@@ -474,7 +483,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }}
/> : ( /> : (
<> <>
{exam.parts[0].intro && ( {exam.parts[0].intro && exam.parts.length !== 1 && (
<SectionNavbar <SectionNavbar
module="level" module="level"
sections={exam.parts} sections={exam.parts}

View File

@@ -1,6 +1,6 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { LevelPart, ListeningPart, ReadingPart, SpeakingExercise, UserSolution, WritingExercise } from "@/interfaces/exam"; import { InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, UserSolution, WritingExercise } from "@/interfaces/exam";
import clsx from "clsx"; import clsx from "clsx";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs"; import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
@@ -10,7 +10,7 @@ interface Props {
sectionLabel: string; sectionLabel: string;
defaultTitle: string; defaultTitle: string;
module: Module; module: Module;
section: LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise; section: LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
onNext: () => void; onNext: () => void;
} }

View File

@@ -1,31 +1,56 @@
import {renderExercise} from "@/components/Exercises"; import { renderExercise } from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions"; import { renderSolution } from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles"; import { infoButtonStyle } from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam"; import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import { defaultUserSolutions } from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import {convertCamelCaseToReadable} from "@/utils/string"; import { convertCamelCaseToReadable } from "@/utils/string";
import {mdiArrowRight} from "@mdi/js"; import { mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
import {Fragment, useEffect, useState} from "react"; import { Fragment, useEffect, useState } from "react";
import {toast} from "react-toastify"; import { toast } from "react-toastify";
import PartDivider from "./Navigation/SectionDivider";
interface Props { interface Props {
exam: SpeakingExam; exam: SpeakingExam;
showSolutions?: boolean; showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void; onFinish: (userSolutions: UserSolution[]) => void;
preview?: boolean;
} }
export default function Speaking({exam, showSolutions = false, onFinish}: Props) { export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) {
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]); const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]);
const {userSolutions, setUserSolutions} = useExamStore((state) => state); const speakingBgColor = "bg-ielts-speaking-light";
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const examState = useExamStore((state) => state);
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const persistentExamState = usePersistentExamStore((state) => state);
const {
userSolutions,
questionIndex,
exerciseIndex,
hasExamEnded,
setBgColor,
setUserSolutions,
setHasExamEnded,
setQuestionIndex,
setExerciseIndex,
} = !preview ? examState : persistentExamState;
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
useEffect(() => {
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
setShowPartDivider(true);
setBgColor(speakingBgColor);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
@@ -38,12 +63,12 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
const nextExercise = (solution?: UserSolution) => { const nextExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
} }
if (questionIndex > 0) { if (questionIndex > 0) {
const exercise = getExercise(); const exercise = getExercise();
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]); setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: questionIndex }]);
} }
setQuestionIndex(0); setQuestionIndex(0);
@@ -57,7 +82,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
setHasExamEnded(false); setHasExamEnded(false);
if (solution) { if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
} else { } else {
onFinish(userSolutions); onFinish(userSolutions);
} }
@@ -66,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
const previousExercise = (solution?: UserSolution) => { const previousExercise = (solution?: UserSolution) => {
scrollToTop(); scrollToTop();
if (solution) { if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
} }
if (exerciseIndex > 0) { if (exerciseIndex > 0) {
@@ -85,24 +110,34 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
return ( return (
<> <>
<div className="flex flex-col h-full w-full gap-8 items-center"> {(showPartDivider) ?
<ModuleTitle <PartDivider
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} sectionLabel="Speaking"
disableTimer={showSolutions} defaultTitle="Speaking exam"
/> section={exam.exercises[exerciseIndex]}
{exerciseIndex > -1 && sectionIndex={exerciseIndex}
exerciseIndex < exam.exercises.length && onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
!showSolutions && /> : (
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)} <div className="flex flex-col h-full w-full gap-8 items-center">
{exerciseIndex > -1 && <ModuleTitle
exerciseIndex < exam.exercises.length && label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
showSolutions && minTimer={exam.minTimer}
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
</div> module="speaking"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions || preview}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
)}
</> </>
); );
} }

View File

@@ -305,6 +305,10 @@ export interface MultipleChoiceExercise {
questions: MultipleChoiceQuestion[]; questions: MultipleChoiceQuestion[];
userSolutions: { question: string; option: string }[]; userSolutions: { question: string; option: string }[];
mcVariant?: string; mcVariant?: string;
passage?: {
title: string;
content: string;
}
} }
export interface MultipleChoiceQuestion { export interface MultipleChoiceQuestion {

View File

@@ -33,7 +33,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const queryParams = queryToURLSearchParams(req); const queryParams = queryToURLSearchParams(req);
let endpoint = queryParams.getAll('module').join("/"); let endpoint = queryParams.getAll('module').join("/");
if (endpoint.startsWith("level")) { if (endpoint.startsWith("level")) {
endpoint = "level" endpoint = "level/"
} }
const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`, const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`,

View File

@@ -15,26 +15,45 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
async function post(req: NextApiRequest, res: NextApiResponse) { async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ ok: false }); if (!req.session.user) return res.status(401).json({ ok: false });
const queryParams = queryToURLSearchParams(req); const queryParams = queryToURLSearchParams(req);
let endpoint = queryParams.getAll('module').join("/"); let endpoint = queryParams.getAll('module').join("/");
const response = await axios.post( if (endpoint === "listening") {
`${process.env.BACKEND_URL}/${endpoint}/media`, const response = await axios.post(
req.body, `${process.env.BACKEND_URL}/${endpoint}/media`,
{ req.body,
headers: { {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, headers: {
'Accept': 'audio/mpeg' Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}, Accept: 'audio/mpeg'
responseType: 'arraybuffer', },
} responseType: 'arraybuffer',
); }
);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'audio/mpeg', 'Content-Type': 'audio/mpeg',
'Content-Length': response.data.length 'Content-Length': response.data.length
}); });
res.end(response.data); res.end(response.data);
return;
}
if (endpoint === "speaking") {
const response = await axios.post(
`${process.env.BACKEND_URL}/${endpoint}/media`,
req.body,
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}
}
);
res.status(200).json(response.data);
return;
}
return res.status(405).json({ "error": "Method not allowed."});
} }

View File

@@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import axios from "axios";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
return res.status(404).json({ ok: false });
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) return res.status(401).json({ ok: false });
const { videoId } = req.query;
const response = await axios.get(
`${process.env.BACKEND_URL}/speaking/media/${videoId}`,
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
}
}
);
return res.status(200).json(response.data);
}

View File

@@ -44,7 +44,6 @@ export default function Generation({ user }: { user: User; }) {
useEffect(() => { useEffect(() => {
const fetchAvatars = async () => { const fetchAvatars = async () => {
const response = await axios.get("/api/exam/avatars"); const response = await axios.get("/api/exam/avatars");
console.log(response.data);
updateRoot({ speakingAvatars: response.data }); updateRoot({ speakingAvatars: response.data });
}; };
@@ -93,7 +92,7 @@ export default function Generation({ user }: { user: User; }) {
}) })
} }
}); });
dispatch({type: 'FULL_RESET'});
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -113,7 +112,7 @@ export default function Generation({ user }: { user: User; }) {
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<Layout user={user} className="gap-6"> <Layout user={user} className="gap-6">
<h1 className="text-2xl font-semibold">Exam Generation</h1> <h1 className="text-2xl font-semibold">Exam Editor</h1>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input <Input
type="text" type="text"

View File

@@ -32,7 +32,7 @@ const defaultSettings = (module: Module) => {
case 'speaking': case 'speaking':
return { return {
...baseSettings, ...baseSettings,
isGenerateAudio: false isGenerateAudioOpen: false
} }
default: default:
return baseSettings; return baseSettings;

View File

@@ -1,16 +1,22 @@
import defaultModuleSettings from "../defaults";
import ExamEditorStore from "../types"; import ExamEditorStore from "../types";
import { MODULE_ACTIONS, ModuleActions, moduleReducer } from "./moduleReducer"; import { MODULE_ACTIONS, ModuleActions, moduleReducer } from "./moduleReducer";
import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReducer"; import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReducer";
type UpdateRoot = {
type: 'UPDATE_ROOT';
payload: {
updates: Partial<ExamEditorStore>
}
};
type FullReset = { type: 'FULL_RESET' };
export type Action = ModuleActions | SectionActions | { type: 'UPDATE_ROOT'; payload: { updates: Partial<ExamEditorStore> } }; export type Action = ModuleActions | SectionActions | UpdateRoot | FullReset;
export const rootReducer = ( export const rootReducer = (
state: ExamEditorStore, state: ExamEditorStore,
action: Action action: Action
): Partial<ExamEditorStore> => { ): Partial<ExamEditorStore> => {
console.log(action.type);
if (MODULE_ACTIONS.includes(action.type as any)) { if (MODULE_ACTIONS.includes(action.type as any)) {
if (action.type === "REORDER_EXERCISES") { if (action.type === "REORDER_EXERCISES") {
const updatedState = sectionReducer(state, action as SectionActions); const updatedState = sectionReducer(state, action as SectionActions);
@@ -45,6 +51,19 @@ export const rootReducer = (
...state, ...state,
...updates ...updates
}; };
case 'FULL_RESET':
return {
title: "",
currentModule: "reading",
speakingAvatars: [],
modules: {
reading: defaultModuleSettings("reading", 60),
writing: defaultModuleSettings("writing", 60),
speaking: defaultModuleSettings("speaking", 14),
listening: defaultModuleSettings("listening", 30),
level: defaultModuleSettings("level", 60)
},
}
default: default:
return {}; return {};
} }

View File

@@ -3,13 +3,6 @@ import { ModuleState } from "../types";
import ReorderResult from "./types"; import ReorderResult from "./types";
const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult<FillBlanksExercise> => { const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult<FillBlanksExercise> => {
let newSolutions = exercise.solutions
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
.map((solution, index) => ({
...solution,
id: (startId + index).toString()
}));
let idMapping = exercise.solutions let idMapping = exercise.solutions
.sort((a, b) => parseInt(a.id) - parseInt(b.id)) .sort((a, b) => parseInt(a.id) - parseInt(b.id))
.reduce((acc, solution, index) => { .reduce((acc, solution, index) => {
@@ -17,20 +10,29 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
return acc; return acc;
}, {} as Record<string, string>); }, {} as Record<string, string>);
let newSolutions = exercise.solutions
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
.map((solution, index) => ({
...solution,
id: (startId + index).toString()
}));
let newText = exercise.text; let newText = exercise.text;
Object.entries(idMapping).forEach(([oldId, newId]) => { Object.entries(idMapping).forEach(([oldId, newId]) => {
const regex = new RegExp(`\\{\\{${oldId}\\}\\}`, 'g'); const regex = new RegExp(`\\{\\{${oldId}\\}\\}`, 'g');
newText = newText.replace(regex, `{{${newId}}}`); newText = newText.replace(regex, `{{${newId}}}`);
}); });
let newWords = exercise.words.map(word => { let newWords = exercise.words.map(word => {
if (typeof word === 'string') { if (typeof word === 'string') {
return word; return word;
} else if ('letter' in word && 'word' in word) { } else if ('letter' in word && 'word' in word) {
return word; return word;
} else if ('options' in word) { } else if ('options' in word && 'id' in word) {
return word; return {
...word,
id: idMapping[word.id] || word.id
};
} }
return word; return word;
}); });
@@ -50,7 +52,6 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
}, },
lastId: startId + newSolutions.length lastId: startId + newSolutions.length
}; };
}; };
const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult<WriteBlanksExercise> => { const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult<WriteBlanksExercise> => {

View File

@@ -22,7 +22,7 @@ export interface SectionSettings {
export interface SpeakingSectionSettings extends SectionSettings { export interface SpeakingSectionSettings extends SectionSettings {
secondTopic?: string; secondTopic?: string;
isGenerateAudio: boolean; isGenerateAudioOpen: boolean;
} }
export interface ReadingSectionSettings extends SectionSettings { export interface ReadingSectionSettings extends SectionSettings {
@@ -34,6 +34,13 @@ export interface ListeningSectionSettings extends SectionSettings {
isAudioGenerationOpen: boolean; isAudioGenerationOpen: boolean;
} }
export interface LevelSectionSettings extends SectionSettings {
readingDropdownOpen: boolean;
writingDropdownOpen: boolean;
speakingDropdownOpen: boolean;
listeningDropdownOpen: boolean;
}
export type Generating = "context" | "exercises" | "media" | undefined; export type Generating = "context" | "exercises" | "media" | undefined;
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;