Merged in feature/ExamGenRework (pull request #108)
Feature/ExamGenRework Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -12,7 +12,7 @@ interface Props {
|
||||
|
||||
const ReferenceViewer: React.FC<Props> = ({ showReference, selectedReference, options, setShowReference, headings = true}) => (
|
||||
<div
|
||||
className={`fixed inset-y-0 right-0 w-96 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${showReference ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
className={`fixed inset-y-0 right-0 w-96 bg-white shadow-lg transform transition-transform duration-300 z-50 ease-in-out ${showReference ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 border-b bg-gray-50 flex justify-between items-center">
|
||||
|
||||
@@ -88,6 +88,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setAlerts([]);
|
||||
setLocal(exercise);
|
||||
setEditing(false);
|
||||
},
|
||||
|
||||
@@ -167,6 +167,8 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
setLocal(exercise);
|
||||
},
|
||||
onMode: () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ interface MessageWithPosition extends ScriptLine {
|
||||
interface Props {
|
||||
section: number;
|
||||
editing?: boolean;
|
||||
local: Script;
|
||||
local?: Script;
|
||||
setLocal: (script: Script) => void;
|
||||
}
|
||||
|
||||
@@ -41,14 +41,32 @@ const colorOptions = [
|
||||
];
|
||||
|
||||
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
|
||||
|
||||
const isConversation = [1, 3].includes(section);
|
||||
const speakerCount = section === 1 ? 2 : 4;
|
||||
|
||||
if (local === undefined) {
|
||||
if (isConversation) {
|
||||
setLocal([]);
|
||||
} else {
|
||||
setLocal('');
|
||||
}
|
||||
}
|
||||
|
||||
const [selectedSpeaker, setSelectedSpeaker] = useState<string>('');
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
|
||||
const speakerCount = section === 1 ? 2 : 4;
|
||||
|
||||
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
|
||||
if (local === undefined) {
|
||||
return Array.from({ length: speakerCount }, (_, index) => ({
|
||||
id: index,
|
||||
name: '',
|
||||
gender: 'male',
|
||||
color: colorOptions[index],
|
||||
position: index % 2 === 0 ? 'left' : 'right'
|
||||
}));
|
||||
}
|
||||
|
||||
const existingScript = local as ScriptLine[];
|
||||
const existingSpeakers = new Set<string>();
|
||||
const speakerGenders = new Map<string, 'male' | 'female'>();
|
||||
@@ -226,7 +244,7 @@ const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLoc
|
||||
<CardContent className="py-10">
|
||||
<div className="space-y-6">
|
||||
{editing && (
|
||||
<div className="bg-white rounded-2xl p-6 shadow-inner border">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-inner border mb-8">
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-6">Edit Conversation</h3>
|
||||
<div className="space-y-4 mb-6">
|
||||
{speakers.map((speaker, index) => (
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { BiQuestionMark } from 'react-icons/bi';
|
||||
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import Header from "../../Shared/Header";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||
import { BsFileText } from "react-icons/bs";
|
||||
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
|
||||
import { RiVideoLine } from "react-icons/ri";
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: InteractiveSpeakingExercise;
|
||||
}
|
||||
|
||||
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const [local, setLocal] = useState(exercise);
|
||||
|
||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
||||
|
||||
const { generating, genResult } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
setEditing(false);
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
},
|
||||
onMode: () => { },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "context") {
|
||||
setEditing(true);
|
||||
setLocal({
|
||||
...local,
|
||||
title: genResult[0].title,
|
||||
prompts: genResult[0].prompts.map((item: any) => ({
|
||||
text: item || "",
|
||||
video_url: ""
|
||||
}))
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: [...prev.prompts, { text: "", video_url: "" }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removePrompt = (index: number) => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePrompt = (index: number, text: string) => {
|
||||
setLocal(prev => {
|
||||
const newPrompts = [...prev.prompts];
|
||||
newPrompts[index] = { ...newPrompts[index], text };
|
||||
return { ...prev, prompts: newPrompts };
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Interactive Speaking Script`}
|
||||
description='Generate or write the scripts for the videos.'
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
mode="edit"
|
||||
module="speaking"
|
||||
/>
|
||||
</div>
|
||||
{generating && generating === "context" ? (
|
||||
<GenLoader module={currentModule} />
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
<CardContent>
|
||||
<div className="flex flex-col py-2 mt-2">
|
||||
<h2 className="font-semibold text-xl mb-2">Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the title"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center mb-4 mt-6">
|
||||
<h2 className="font-semibold text-xl">Questions</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{local.prompts.length === 0 ? (
|
||||
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||
<p className="text-gray-600">No questions added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
local.prompts.map((prompt, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-lg pt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||
onClick={() => removePrompt(index)}
|
||||
>
|
||||
<AiOutlineDelete className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<AutoExpandingTextArea
|
||||
value={prompt.text}
|
||||
onChange={(text) => updatePrompt(index, text)}
|
||||
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||
placeholder={`Enter question ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPrompt}
|
||||
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||
>
|
||||
<AiOutlinePlus className="h-5 w-5" />
|
||||
Add Question
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : isUnedited ? (
|
||||
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||
Generate or edit the questions!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Title</h3>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.title || 'Untitled'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Questions</h3>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
{local.prompts
|
||||
.filter(prompt => prompt.text !== "")
|
||||
.map((prompt, index) => (
|
||||
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
|
||||
<p className="text-gray-700">{prompt.text}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractiveSpeaking;
|
||||
364
src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx
Normal file
364
src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||
import Header from "../../Shared/Header";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||
import { BsFileText } from "react-icons/bs";
|
||||
import { RiVideoLine } from 'react-icons/ri';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: InteractiveSpeakingExercise;
|
||||
}
|
||||
|
||||
const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const [local, setLocal] = useState(() => {
|
||||
const defaultPrompts = [
|
||||
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
|
||||
{ text: "Do you work or do you study?", video_url: "" },
|
||||
...exercise.prompts.slice(2)
|
||||
];
|
||||
return { ...exercise, prompts: defaultPrompts };
|
||||
});
|
||||
|
||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
||||
|
||||
const { generating, genResult } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
setEditing(false);
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal({
|
||||
...exercise,
|
||||
prompts: [
|
||||
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
|
||||
{ text: "Do you work or do you study?", video_url: "" },
|
||||
...exercise.prompts.slice(2)
|
||||
]
|
||||
});
|
||||
},
|
||||
onMode: () => { },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "context") {
|
||||
setEditing(true);
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
first_title: genResult[0].first_topic,
|
||||
second_title: genResult[0].second_topic,
|
||||
prompts: [
|
||||
prev.prompts[0],
|
||||
prev.prompts[1],
|
||||
...genResult[0].prompts.map((item: any) => ({
|
||||
text: item,
|
||||
video_url: ""
|
||||
}))
|
||||
]
|
||||
}));
|
||||
|
||||
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 = () => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: [...prev.prompts, { text: "", video_url: "" }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removePrompt = (index: number) => {
|
||||
if (index < 2) return;
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePrompt = (index: number, text: string) => {
|
||||
if (index < 2) return;
|
||||
setLocal(prev => {
|
||||
const newPrompts = [...prev.prompts];
|
||||
newPrompts[index] = { ...newPrompts[index], text };
|
||||
return { ...prev, prompts: newPrompts };
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Speaking 1 Script`}
|
||||
description='Generate or write the scripts for the videos.'
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
mode="edit"
|
||||
module="speaking"
|
||||
/>
|
||||
</div>
|
||||
{generating && generating === "context" ? (
|
||||
<GenLoader module={currentModule} />
|
||||
) : (
|
||||
<>
|
||||
{editing ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="py-2 mt-2">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Titles</h3>
|
||||
</div>
|
||||
<div className="flex gap-6 mt-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-lg mb-2">First Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.first_title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, first_title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the first title"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-lg mb-2">Second Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.second_title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, second_title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the second title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4 mt-6">
|
||||
<h2 className="font-semibold text-xl">Questions</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{local.prompts.length === 2 ? (
|
||||
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||
<p className="text-gray-600">No questions added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
local.prompts.slice(2).map((prompt, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-lg pt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||
onClick={() => removePrompt(index + 2)}
|
||||
>
|
||||
<AiOutlineDelete className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<AutoExpandingTextArea
|
||||
value={prompt.text}
|
||||
onChange={(text) => updatePrompt(index + 2, text)}
|
||||
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||
placeholder={`Enter question ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPrompt}
|
||||
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||
>
|
||||
<AiOutlinePlus className="h-5 w-5" />
|
||||
Add Question
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : isUnedited ? (
|
||||
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||
Generate or edit the questions!
|
||||
</p>
|
||||
) : (
|
||||
<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>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex flex-row mb-4 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Titles</h3>
|
||||
</div>
|
||||
<div className="w-full flex gap-6 mt-6">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-700 mb-2">First Title</h4>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.first_title || 'No first title'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Second Title</h4>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.second_title || 'No second title'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Questions</h3>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
{local.prompts.slice(2)
|
||||
.filter(prompt => prompt.text !== "")
|
||||
.map((prompt, index) => (
|
||||
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
|
||||
<p className="text-gray-700">{prompt.text}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Speaking1;
|
||||
321
src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx
Normal file
321
src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||
import { SpeakingExercise } from "@/interfaces/exam";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import Header from "../../Shared/Header";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { BsFileText } from 'react-icons/bs';
|
||||
import { AiOutlineUnorderedList } from 'react-icons/ai';
|
||||
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import { RiVideoLine } from 'react-icons/ri';
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: SpeakingExercise;
|
||||
}
|
||||
|
||||
|
||||
const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const [local, setLocal] = useState(exercise);
|
||||
|
||||
const { generating, genResult } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
setEditing(false);
|
||||
console.log(local);
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
},
|
||||
onMode: () => { },
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "context") {
|
||||
setEditing(true);
|
||||
setLocal({
|
||||
...local,
|
||||
title: genResult[0].topic,
|
||||
text: genResult[0].question,
|
||||
prompts: genResult[0].prompts
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: currentModule,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 = () => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: [...prev.prompts, ""]
|
||||
}));
|
||||
};
|
||||
|
||||
const removePrompt = (index: number) => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePrompt = (index: number, text: string) => {
|
||||
setLocal(prev => {
|
||||
const newPrompts = [...prev.prompts];
|
||||
newPrompts[index] = text;
|
||||
return { ...prev, prompts: newPrompts };
|
||||
});
|
||||
};
|
||||
|
||||
const isUnedited = local.text === "" ||
|
||||
(local.title === undefined || local.title === "") ||
|
||||
local.prompts.length === 0;
|
||||
|
||||
const tooltipContent = `
|
||||
<div class='p-2 max-w-xs'>
|
||||
<p class='text-sm text-white'>
|
||||
Prompts are guiding points that help candidates structure their talk. They typically include aspects like:
|
||||
<ul class='list-disc pl-4 mt-1'>
|
||||
<li>Describing what/who/where</li>
|
||||
<li>Explaining why</li>
|
||||
<li>Sharing feelings or preferences</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Speaking ${sectionId} Script`}
|
||||
description='Generate or write the script for the video.'
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
mode="edit"
|
||||
module="speaking"
|
||||
/>
|
||||
</div>
|
||||
{generating && generating === "context" ? (
|
||||
<GenLoader module={currentModule} />
|
||||
) : (
|
||||
<>
|
||||
{editing ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col py-2 mt-2">
|
||||
<h2 className="font-semibold text-xl mb-2">Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the topic"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col py-2 mt-2">
|
||||
<h2 className="font-semibold text-xl mb-2">Question</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.text}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, text: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the main question"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4 mt-6">
|
||||
<h2 className="font-semibold text-xl">Prompts</h2>
|
||||
<Tooltip id="prompt-tp" />
|
||||
<a
|
||||
data-tooltip-id="prompt-tp"
|
||||
data-tooltip-html={tooltipContent}
|
||||
className='ml-1 w-6 h-6 flex items-center justify-center rounded-full hover:bg-gray-200 border bg-gray-100'
|
||||
>
|
||||
<BiQuestionMark
|
||||
className="w-5 h-5 text-gray-500"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{local.prompts.length === 0 ? (
|
||||
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||
<p className="text-gray-600">No prompts added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
local.prompts.map((prompt, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-lg pt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-700">Prompt {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||
onClick={() => removePrompt(index)}
|
||||
>
|
||||
<AiOutlineDelete className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<AutoExpandingTextArea
|
||||
value={prompt}
|
||||
onChange={(text) => updatePrompt(index, text)}
|
||||
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||
placeholder={`Enter prompt ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPrompt}
|
||||
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||
>
|
||||
<AiOutlinePlus className="h-5 w-5" />
|
||||
Add Prompt
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : isUnedited ? (
|
||||
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||
Generate or edit the script!
|
||||
</p>
|
||||
) : (
|
||||
<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>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Title</h3>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.title || 'Untitled'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BiMessageRoundedDetail className="h-5 w-5 text-green-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Question</h3>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.text || 'No question provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{local.prompts && local.prompts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Prompts</h3>
|
||||
</div>
|
||||
<div className="w-full p-4 bg-gray-50 shadow-inner rounded-lg border border-gray-100">
|
||||
<div className="flex flex-col gap-3">
|
||||
{local.prompts.map((prompt, index) => (
|
||||
<div key={index} className="px-4 py-3 bg-white shadow rounded-lg border border-gray-100">
|
||||
<p className="text-gray-700">{prompt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Speaking2;
|
||||
@@ -7,6 +7,9 @@ import Header from "../../Shared/Header";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import Speaking2 from "./Speaking2";
|
||||
import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
import Speaking1 from "./Speaking1";
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
@@ -14,176 +17,24 @@ interface Props {
|
||||
}
|
||||
|
||||
const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const { generating, genResult } = useExamEditorStore(
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [loading, setLoading] = useState(generating === "context");
|
||||
const [questions, setQuestions] = useState(() => {
|
||||
if (sectionId === 1) {
|
||||
if ((exercise as SpeakingExercise).prompts.length > 0) {
|
||||
return (exercise as SpeakingExercise).prompts;
|
||||
}
|
||||
return Array(5).fill("");
|
||||
} else if (sectionId === 2) {
|
||||
if ((exercise as SpeakingExercise).text && (exercise as SpeakingExercise).prompts.length > 0) {
|
||||
return (exercise as SpeakingExercise).prompts;
|
||||
}
|
||||
return Array(3).fill("");
|
||||
} else {
|
||||
if ((exercise as InteractiveSpeakingExercise).prompts.length > 0) {
|
||||
return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text);
|
||||
}
|
||||
return Array(5).fill("");
|
||||
}
|
||||
});
|
||||
|
||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
||||
}, [dispatch]);
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||
sectionId,
|
||||
mode: "edit",
|
||||
onSave: () => {
|
||||
let newExercise;
|
||||
if (sectionId === 1) {
|
||||
newExercise = {
|
||||
...local,
|
||||
prompts: questions
|
||||
} as SpeakingExercise;
|
||||
} else if (sectionId === 2) {
|
||||
newExercise = {
|
||||
...local,
|
||||
text: questions[0],
|
||||
prompts: questions.slice(1),
|
||||
} as SpeakingExercise;
|
||||
} else {
|
||||
newExercise = {
|
||||
...local,
|
||||
prompts: questions.map(text => ({
|
||||
text,
|
||||
video_url: (local as InteractiveSpeakingExercise).prompts?.[0]?.video_url || ""
|
||||
}))
|
||||
} as InteractiveSpeakingExercise;
|
||||
}
|
||||
setEditing(false);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId: sectionId,
|
||||
update: newExercise
|
||||
}
|
||||
});
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
},
|
||||
onMode: () => {
|
||||
setLocal(exercise);
|
||||
if (sectionId === 1) {
|
||||
setQuestions(Array(5).fill(""));
|
||||
} else if (sectionId === 2) {
|
||||
setQuestions(Array(3).fill(""));
|
||||
} else {
|
||||
setQuestions(Array(5).fill(""));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const isLoading = generating === "context";
|
||||
setLoading(isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
updateModule({ edit: Array.from(new Set([...edit, sectionId])) });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [generating, sectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "context") {
|
||||
setEditing(true);
|
||||
if (sectionId === 1) {
|
||||
setQuestions(genResult[0].questions);
|
||||
} else if (sectionId === 2) {
|
||||
setQuestions([genResult[0].question, ...genResult[0].prompts]);
|
||||
} else {
|
||||
setQuestions(genResult[0].questions);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: currentModule,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [genResult, generating, dispatch, sectionId, setEditing, currentModule]);
|
||||
|
||||
const handleQuestionChange = (index: number, value: string) => {
|
||||
setQuestions(prev => {
|
||||
const newQuestions = [...prev];
|
||||
newQuestions[index] = value;
|
||||
return newQuestions;
|
||||
});
|
||||
};
|
||||
|
||||
const getQuestionLabel = (index: number) => {
|
||||
if (sectionId === 2 && index === 0) {
|
||||
return "Main Question";
|
||||
} else if (sectionId === 2) {
|
||||
return `Prompt ${index}`;
|
||||
} else {
|
||||
return `Question ${index + 1}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Speaking ${sectionId} Script`}
|
||||
description='Generate or write the script for the video.'
|
||||
editing={editing}
|
||||
handleSave={handleSave}
|
||||
modeHandle={modeHandle}
|
||||
handleDiscard={handleDiscard}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<GenLoader module={currentModule} />
|
||||
) : (
|
||||
<div className="mx-auto p-3 space-y-6">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{questions.map((question: string, index: number) => (
|
||||
<div key={index} className="flex flex-col">
|
||||
<h2 className="font-semibold my-2">{getQuestionLabel(index)}</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={question}
|
||||
onChange={(text) => handleQuestionChange(index, text)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder={`Enter ${getQuestionLabel(index).toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mx-auto p-3 space-y-6">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}
|
||||
{sectionId === 2 && <Speaking2 sectionId={sectionId} exercise={state as SpeakingExercise} />}
|
||||
{sectionId === 3 && <InteractiveSpeaking sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Speaking;
|
||||
export default Speaking;
|
||||
@@ -6,6 +6,9 @@ import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import ScriptRender from "../../Exercises/Script";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import Dropdown from "@/components/Dropdown";
|
||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||
import { MdHeadphones } from "react-icons/md";
|
||||
import clsx from "clsx";
|
||||
|
||||
|
||||
const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||
@@ -41,7 +44,7 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||
|
||||
const renderContent = (editing: boolean) => {
|
||||
if (scriptLocal === undefined) {
|
||||
if (scriptLocal === undefined && !editing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-10">
|
||||
@@ -50,20 +53,38 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
<Dropdown
|
||||
className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
|
||||
title="Conversation"
|
||||
contentWrapperClassName="rounded-xl"
|
||||
>
|
||||
<ScriptRender
|
||||
local={scriptLocal}
|
||||
setLocal={setScriptLocal}
|
||||
section={sectionId}
|
||||
editing={editing}
|
||||
/>
|
||||
</Dropdown>
|
||||
return (
|
||||
<>
|
||||
|
||||
{listeningPart.audio?.source && (
|
||||
<AudioPlayer
|
||||
key={sectionId}
|
||||
src={listeningPart.audio?.source ?? ''}
|
||||
color="listening"
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
className="mt-8 w-full flex items-center justify-between p-4 bg-white hover:bg-gray-50 transition-colors border rounded-xl border-gray-200"
|
||||
contentWrapperClassName="rounded-xl mt-2"
|
||||
customTitle={
|
||||
<div className="flex items-center space-x-3">
|
||||
<MdHeadphones className={clsx(
|
||||
"h-5 w-5",
|
||||
`text-ielts-${currentModule}`
|
||||
)} />
|
||||
<span className="font-medium text-gray-900">{(sectionId === 1 || sectionId === 3) ? "Conversation" : "Monologue"}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ScriptRender
|
||||
local={scriptLocal}
|
||||
setLocal={setScriptLocal}
|
||||
section={sectionId}
|
||||
editing={editing}
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => {
|
||||
useEffect(()=> {
|
||||
if (genResult !== undefined && generating === "context") {
|
||||
setEditing(true);
|
||||
console.log(genResult);
|
||||
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}})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -131,7 +131,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||
collisionDetection={closestCenter}
|
||||
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>)
|
||||
) : (
|
||||
expandedSections.includes(sectionId) &&
|
||||
|
||||
@@ -3,6 +3,7 @@ import ExerciseItem, { isExerciseItem } from "./types";
|
||||
import ExerciseLabel from "../../Shared/ExerciseLabel";
|
||||
import MultipleChoice from "../../Exercises/MultipleChoice";
|
||||
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
|
||||
import Passage from "../../Shared/Passage";
|
||||
|
||||
const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
|
||||
|
||||
@@ -14,6 +15,19 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
||||
let firstWordId, lastWordId;
|
||||
switch (exercise.type) {
|
||||
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;
|
||||
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||
return {
|
||||
@@ -21,7 +35,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
||||
sectionId,
|
||||
label: (
|
||||
<ExerciseLabel
|
||||
label={`Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
|
||||
label={isReadingPassage ? `Reading Passage: MC Questions #${firstWordId} - #${lastWordId}` : `Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
|
||||
preview={
|
||||
<>
|
||||
"{previewLabel(exercise.prompt)}..."
|
||||
@@ -29,7 +43,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
||||
}
|
||||
/>
|
||||
),
|
||||
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
||||
content
|
||||
};
|
||||
case "fillBlanks":
|
||||
firstWordId = exercise.solutions[0].id;
|
||||
@@ -39,7 +53,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
||||
sectionId,
|
||||
label: (
|
||||
<ExerciseLabel
|
||||
label={`Fill Blanks Question #${firstWordId} - #${lastWordId}`}
|
||||
label={`Fill Blanks: MC Question #${firstWordId} - #${lastWordId}`}
|
||||
preview={
|
||||
<>
|
||||
"{previewLabel(exercise.prompt)}..."
|
||||
@@ -54,7 +68,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
||||
}
|
||||
}).filter(isExerciseItem);
|
||||
|
||||
return items;
|
||||
return items;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import ExerciseLabel from '../../Shared/ExerciseLabel';
|
||||
import MatchSentences from '../../Exercises/MatchSentences';
|
||||
import TrueFalse from '../../Exercises/TrueFalse';
|
||||
import FillBlanksLetters from '../../Exercises/Blanks/Letters';
|
||||
import MultipleChoice from '../../Exercises/MultipleChoice';
|
||||
|
||||
const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): ExerciseItem[] => {
|
||||
|
||||
@@ -87,6 +88,24 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
|
||||
),
|
||||
content: <TrueFalse exercise={exercise} sectionId={sectionId} />
|
||||
};
|
||||
case "multipleChoice":
|
||||
firstWordId = exercise.questions[0].id;
|
||||
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||
return {
|
||||
id: index.toString(),
|
||||
sectionId,
|
||||
label: (
|
||||
<ExerciseLabel
|
||||
label={`Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
|
||||
preview={
|
||||
<>
|
||||
"{previewLabel(exercise.prompt)}..."
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
||||
};
|
||||
|
||||
}
|
||||
}).filter(isExerciseItem);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
||||
import { FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
||||
|
||||
export default interface ExerciseItem {
|
||||
id: string;
|
||||
@@ -7,7 +7,7 @@ export default interface ExerciseItem {
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise;
|
||||
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise | MultipleChoiceExercise;
|
||||
|
||||
export function isExerciseItem(item: unknown): item is ExerciseItem {
|
||||
return item !== undefined &&
|
||||
|
||||
@@ -10,17 +10,24 @@ interface Props {
|
||||
sectionId: number;
|
||||
genType: Generating;
|
||||
generateFnc: (sectionId: number) => void
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc}) => {
|
||||
const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!;
|
||||
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => {
|
||||
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;
|
||||
return (
|
||||
<button
|
||||
key={`section-${sectionId}`}
|
||||
className={clsx(
|
||||
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module}`
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module}`,
|
||||
className
|
||||
)}
|
||||
onClick={loading ? () => { } : () => generateFnc(sectionId)}
|
||||
>
|
||||
|
||||
@@ -9,9 +9,10 @@ interface Props {
|
||||
disabled?: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
children: ReactNode;
|
||||
center?: boolean;
|
||||
}
|
||||
|
||||
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, disabled = false }) => {
|
||||
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, disabled = false, center = false}) => {
|
||||
return (
|
||||
<Dropdown
|
||||
title={title}
|
||||
@@ -20,7 +21,7 @@ const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, chi
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||
open ? "rounded-t-lg" : "rounded-lg"
|
||||
)}
|
||||
contentWrapperClassName="pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"
|
||||
contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${center ? "flex justify-center" : ""}`}
|
||||
open={open}
|
||||
setIsOpen={setIsOpen}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
|
||||
});
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
@@ -7,15 +7,33 @@ import ExercisePicker from "../Shared/ExercisePicker";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
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 {currentModule } = useExamEditorStore();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const {currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
||||
@@ -23,45 +41,80 @@ const LevelSettings: React.FC = () => {
|
||||
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[] = [
|
||||
{
|
||||
label: "Preset: Multiple Choice",
|
||||
value: "Not available."
|
||||
},
|
||||
{
|
||||
label: "Preset: Multiple Choice - Blank Space",
|
||||
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\".",
|
||||
},
|
||||
{
|
||||
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 currentSection = section.state as LevelPart;
|
||||
|
||||
const canPreview = currentSection.exercises.length > 0;
|
||||
|
||||
const submitLevel = () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
];
|
||||
const exam: LevelExam = {
|
||||
parts: sections.map((s) => {
|
||||
const part = s.state as LevelPart;
|
||||
return {
|
||||
...part,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "level",
|
||||
id: title,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
axios.post(`/api/exam/level`, exam)
|
||||
.then((result) => {
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
||||
})
|
||||
}
|
||||
|
||||
const 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 (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Part ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="level"
|
||||
introPresets={defaultPresets}
|
||||
preview={()=>{}}
|
||||
canPreview={false}
|
||||
canSubmit={false}
|
||||
submitModule={()=> {}}
|
||||
introPresets={[]}
|
||||
preview={preview}
|
||||
canPreview={canPreview}
|
||||
canSubmit={canPreview}
|
||||
submitModule={submitLevel}
|
||||
>
|
||||
<div>
|
||||
<Dropdown title="Add Exercises" className={
|
||||
|
||||
@@ -19,13 +19,13 @@ import { toast } from "react-toastify";
|
||||
|
||||
const ListeningSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const {currentModule, title } = useExamEditorStore();
|
||||
const { currentModule, title, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate
|
||||
isPrivate,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const {
|
||||
@@ -33,6 +33,7 @@ const ListeningSettings: React.FC = () => {
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
|
||||
@@ -40,17 +41,25 @@ const ListeningSettings: React.FC = () => {
|
||||
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[] = [
|
||||
{
|
||||
label: "Preset: Writing Task 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."
|
||||
},
|
||||
{
|
||||
label: "Preset: Writing Task 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."
|
||||
}
|
||||
label: "Preset: Listening Section 1",
|
||||
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: Listening Section 2",
|
||||
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."
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -77,39 +86,81 @@ const ListeningSettings: React.FC = () => {
|
||||
updateLocalAndScheduleGlobal({ topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const submitListening = () => {
|
||||
const submitListening = async () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
const exam: ListeningExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ListeningPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
};
|
||||
try {
|
||||
const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source);
|
||||
|
||||
axios.post(`/api/exam/listening`, exam)
|
||||
.then((result) => {
|
||||
if (sectionsWithAudio.length > 0) {
|
||||
const formData = new FormData();
|
||||
const sectionMap = new Map<number, string>();
|
||||
|
||||
await Promise.all(
|
||||
sectionsWithAudio.map(async (section) => {
|
||||
const listeningPart = section.state as ListeningPart;
|
||||
const blobUrl = listeningPart.audio!.source;
|
||||
const response = await fetch(blobUrl);
|
||||
const blob = await response.blob();
|
||||
formData.append('file', blob, 'audio.mp3');
|
||||
sectionMap.set(section.sectionId, blobUrl);
|
||||
})
|
||||
);
|
||||
|
||||
const response = await axios.post('/api/storage', formData, {
|
||||
params: {
|
||||
directory: 'listening_recordings'
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
const { urls } = response.data;
|
||||
|
||||
const exam: ListeningExam = {
|
||||
parts: sections.map((s) => {
|
||||
const exercise = s.state as ListeningPart;
|
||||
const index = Array.from(sectionMap.entries())
|
||||
.findIndex(([id]) => id === s.sectionId);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
audio: exercise.audio ? {
|
||||
...exercise.audio,
|
||||
source: index !== -1 ? urls[index] : exercise.audio.source
|
||||
} : undefined,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
isDiagnostic: false,
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/listening', exam);
|
||||
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.");
|
||||
})
|
||||
}
|
||||
|
||||
} else {
|
||||
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error submitting exam:', error);
|
||||
toast.error(
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
@@ -117,32 +168,84 @@ const ListeningSettings: React.FC = () => {
|
||||
const exercise = s.state as ListeningPart;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "listening",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
} as ListeningExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=listening", router)
|
||||
}
|
||||
|
||||
const generateAudio = useCallback(async (sectionId: number) => {
|
||||
let body: any;
|
||||
if ([1, 3].includes(sectionId)) {
|
||||
body = { conversation: currentSection.script }
|
||||
} else {
|
||||
body = { monologue: currentSection.script }
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: "media"}});
|
||||
const response = await axios.post(
|
||||
'/api/exam/media/listening',
|
||||
body,
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'Accept': 'audio/mpeg'
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const blob = new Blob([response.data], { type: 'audio/mpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
if (currentSection.audio?.source) {
|
||||
URL.revokeObjectURL(currentSection.audio?.source)
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId,
|
||||
update: {
|
||||
audio: {
|
||||
source: url,
|
||||
repeatableTimes: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
toast.success('Audio generated successfully!');
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to generate audio');
|
||||
} finally {
|
||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: undefined}});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSection?.script, dispatch]);
|
||||
|
||||
const canPreview = sections.some(
|
||||
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
||||
);
|
||||
|
||||
const canSubmit = sections.every(
|
||||
(s) => (s.state as ListeningPart).exercises &&
|
||||
(s.state as ListeningPart).exercises.length > 0 &&
|
||||
(s.state as ListeningPart).audio !== undefined
|
||||
(s) => (s.state as ListeningPart).exercises &&
|
||||
(s.state as ListeningPart).exercises.length > 0 &&
|
||||
(s.state as ListeningPart).audio !== undefined
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -190,7 +293,7 @@ const ListeningSettings: React.FC = () => {
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
disabled={currentSection.script === undefined && currentSection.audio === undefined}
|
||||
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="listening"
|
||||
@@ -198,22 +301,23 @@ const ListeningSettings: React.FC = () => {
|
||||
difficulty={difficulty}
|
||||
/>
|
||||
</Dropdown>
|
||||
{/*
|
||||
|
||||
<Dropdown
|
||||
title="Generate Audio"
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
||||
disabled={currentSection.script === undefined && currentSection.audio === undefined}
|
||||
open={localSettings.isAudioGenerationOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
|
||||
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
|
||||
center
|
||||
>
|
||||
<ExercisePicker
|
||||
module="listening"
|
||||
<GenerateBtn
|
||||
module={currentModule}
|
||||
genType="media"
|
||||
sectionId={focusedSection}
|
||||
selectedExercises={selectedExercises}
|
||||
setSelectedExercises={setSelectedExercises}
|
||||
difficulty={difficulty}
|
||||
generateFnc={generateAudio}
|
||||
className="mb-4"
|
||||
/>
|
||||
</Dropdown>*/}
|
||||
</Dropdown>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ const ReadingSettings: React.FC = () => {
|
||||
setExerciseIndex,
|
||||
setPartIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
@@ -41,17 +42,22 @@ const ReadingSettings: React.FC = () => {
|
||||
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[] = [
|
||||
{
|
||||
label: "Preset: Writing Task 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."
|
||||
},
|
||||
{
|
||||
label: "Preset: Writing Task 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."
|
||||
}
|
||||
label: "Preset: Reading Passage 1",
|
||||
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: Reading Passage 2",
|
||||
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;
|
||||
return {
|
||||
...exercise,
|
||||
intro: localSettings.currentIntro,
|
||||
category: localSettings.category
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
@@ -140,6 +146,7 @@ const ReadingSettings: React.FC = () => {
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setPartIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=reading", router)
|
||||
}
|
||||
|
||||
@@ -188,13 +195,13 @@ const ReadingSettings: React.FC = () => {
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
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
|
||||
module="reading"
|
||||
sectionId={focusedSection}
|
||||
difficulty={difficulty}
|
||||
extraArgs={{ text: currentSection.text.content }}
|
||||
extraArgs={{ text: currentSection === undefined ? "" : currentSection.text.content }}
|
||||
/>
|
||||
</Dropdown>
|
||||
</SettingsEditor>
|
||||
|
||||
@@ -2,45 +2,75 @@ import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
|
||||
import Option from "@/interfaces/option";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { generate } from "./Shared/Generate";
|
||||
import SettingsEditor from ".";
|
||||
import Dropdown from "./Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import GenerateBtn from "./Shared/GenerateBtn";
|
||||
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 { currentModule, dispatch } = useExamEditorStore();
|
||||
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
const router = useRouter();
|
||||
|
||||
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>(
|
||||
currentModule,
|
||||
focusedSection,
|
||||
);
|
||||
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Writing Task 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."
|
||||
},
|
||||
{
|
||||
label: "Preset: Writing Task 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."
|
||||
}
|
||||
label: "Preset: Speaking Part 1",
|
||||
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: Speaking Part 2",
|
||||
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 queryParams: {
|
||||
difficulty: string;
|
||||
first_topic?: string;
|
||||
second_topic?: string;
|
||||
topic?: string;
|
||||
} = { difficulty };
|
||||
|
||||
|
||||
if (sectionId === 1) {
|
||||
if (localSettings.topic) {
|
||||
queryParams['first_topic'] = localSettings.topic;
|
||||
@@ -57,7 +87,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
generate(
|
||||
sectionId,
|
||||
currentModule,
|
||||
"context",
|
||||
"context", // <- not really context but exercises is reserved for reading, listening and level
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams
|
||||
@@ -66,9 +96,9 @@ const SpeakingSettings: React.FC = () => {
|
||||
switch (sectionId) {
|
||||
case 1:
|
||||
return [{
|
||||
questions: data.questions,
|
||||
firstTopic: data.first_topic,
|
||||
secondTopic: data.second_topic
|
||||
prompts: data.questions,
|
||||
first_topic: data.first_topic,
|
||||
second_topic: data.second_topic
|
||||
}];
|
||||
case 2:
|
||||
return [{
|
||||
@@ -79,15 +109,15 @@ const SpeakingSettings: React.FC = () => {
|
||||
}];
|
||||
case 3:
|
||||
return [{
|
||||
topic: data.topic,
|
||||
questions: data.questions
|
||||
title: data.topic,
|
||||
prompts: data.questions
|
||||
}];
|
||||
default:
|
||||
return [data];
|
||||
}
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings, difficulty]);
|
||||
|
||||
const onTopicChange = useCallback((topic: string) => {
|
||||
@@ -95,19 +125,259 @@ const SpeakingSettings: React.FC = () => {
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const onSecondTopicChange = useCallback((topic: string) => {
|
||||
updateLocalAndScheduleGlobal({ });
|
||||
updateLocalAndScheduleGlobal({ secondTopic: topic });
|
||||
}, [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 (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Speaking ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="speaking"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={() => { }}
|
||||
canPreview={false}
|
||||
canSubmit={false}
|
||||
submitModule={()=> {}}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitSpeaking}
|
||||
>
|
||||
<Dropdown
|
||||
title="Generate Script"
|
||||
@@ -116,7 +386,7 @@ const SpeakingSettings: React.FC = () => {
|
||||
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">
|
||||
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
|
||||
<Input
|
||||
@@ -153,6 +423,57 @@ const SpeakingSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
title="Generate Video"
|
||||
module={currentModule}
|
||||
open={localSettings.isGenerateAudioOpen}
|
||||
disabled={!canGenerate}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)}
|
||||
>
|
||||
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<select
|
||||
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "") {
|
||||
setSelectedAvatar(null);
|
||||
} 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>
|
||||
|
||||
<GenerateBtn
|
||||
module={currentModule}
|
||||
genType="media"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateVideoCallback}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -122,22 +122,50 @@ const ExerciseWizard: React.FC<Props> = ({
|
||||
size={28}
|
||||
color={!currentValue ? `#F3F4F6` : `#1F2937`}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<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'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt={"AI Generated?"} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const inputValue = Number(config.params[param.param || '1'].toString());
|
||||
|
||||
const isParagraphMatch = config.type.split("?name=")[1] === "paragraphMatch";
|
||||
const maxParagraphs = isParagraphMatch ? extraArgs!.text.split("\n\n").length : 50;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -183,7 +211,8 @@ const ExerciseWizard: React.FC<Props> = ({
|
||||
<exercise.icon className="h-5 w-5" />
|
||||
<h3 className="font-medium text-lg">{exercise.label}</h3>
|
||||
</div>
|
||||
{generateParam && renderParameterInput(generateParam, exerciseIndex, config)}
|
||||
{/* when placeholders are done uncomment this*/}
|
||||
{/*generateParam && renderParameterInput(generateParam, exerciseIndex, config)*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -209,21 +209,29 @@ const listening = (section: number) => {
|
||||
}
|
||||
|
||||
const EXERCISES: ExerciseGen[] = [
|
||||
{
|
||||
/*{
|
||||
label: "Multiple Choice",
|
||||
type: "multipleChoice",
|
||||
icon: FaListUl,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "multipleChoice"
|
||||
},
|
||||
quantity(10, "Amount"),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
},*/
|
||||
{
|
||||
label: "Multiple Choice - Blank Space",
|
||||
type: "mcBlank",
|
||||
icon: FaEdit,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "mcBlank"
|
||||
},
|
||||
quantity(10, "Amount"),
|
||||
generate()
|
||||
],
|
||||
@@ -234,6 +242,10 @@ const EXERCISES: ExerciseGen[] = [
|
||||
type: "mcUnderline",
|
||||
icon: FaUnderline,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "mcUnderline"
|
||||
},
|
||||
quantity(10, "Amount"),
|
||||
generate()
|
||||
],
|
||||
@@ -255,10 +267,14 @@ const EXERCISES: ExerciseGen[] = [
|
||||
module: "level"
|
||||
},*/
|
||||
{
|
||||
label: "Fill Blanks: MC",
|
||||
label: "Fill Blanks: Multiple Choice",
|
||||
type: "fillBlanksMC",
|
||||
icon: FaPen,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "fillBlanksMC"
|
||||
},
|
||||
quantity(10, "Nº of Blanks"),
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
@@ -270,25 +286,26 @@ const EXERCISES: ExerciseGen[] = [
|
||||
module: "level"
|
||||
},
|
||||
{
|
||||
label: "Reading Passage",
|
||||
label: "Reading Passage: Multiple Choice",
|
||||
type: "passageUtas",
|
||||
icon: FaBookOpen,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "passageUtas"
|
||||
},
|
||||
// in the utas exam there was only mc so I'm assuming short answers are deprecated
|
||||
/*{
|
||||
label: "Short Answers",
|
||||
param: "sa_qty",
|
||||
value: "10"
|
||||
},*/
|
||||
{
|
||||
label: "Multiple Choice Quantity",
|
||||
param: "mc_qty",
|
||||
value: "10"
|
||||
},
|
||||
quantity(10, "Multiple Choice Quantity"),
|
||||
{
|
||||
label: "Reading Passage Topic",
|
||||
param: "topic",
|
||||
value: ""
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
|
||||
@@ -17,6 +17,6 @@ export interface ExerciseGen {
|
||||
type: string;
|
||||
icon: IconType;
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { generate } from "../../SettingsEditor/Shared/Generate";
|
||||
import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
|
||||
interface ExercisePickerProps {
|
||||
module: string;
|
||||
@@ -22,13 +23,15 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||
extraArgs = undefined,
|
||||
}) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const { difficulty, sections } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
const section = sections.find((s) => s.sectionId == sectionId)!;
|
||||
const { state, selectedExercises } = section;
|
||||
|
||||
const { difficulty } = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
const section = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId == sectionId));
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
if (section === undefined) return <></>;
|
||||
|
||||
const { state, selectedExercises } = section;
|
||||
|
||||
const getFullExerciseType = (exercise: ExerciseGen): string => {
|
||||
if (exercise.extra && exercise.extra.length > 0) {
|
||||
const extraValue = exercise.extra.find(e => e.param === 'name')?.value;
|
||||
@@ -47,7 +50,7 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||
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 exercises = configurations.map(config => {
|
||||
@@ -111,14 +114,22 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||
exercises: data.exercises
|
||||
}]
|
||||
);
|
||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "selectedExercises", value: []}})
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "selectedExercises", value: [] } })
|
||||
setPickerOpen(false);
|
||||
};
|
||||
|
||||
|
||||
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
|
||||
sectionId={sectionId}
|
||||
exercises={moduleExercises}
|
||||
@@ -162,7 +173,13 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={selectedExercises.length == 0}
|
||||
>
|
||||
Set Up Exercises ({selectedExercises.length})
|
||||
{section.generating === "exercises" ? (
|
||||
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
<>Set Up Exercises ({selectedExercises.length}) </>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { capitalize } from 'lodash';
|
||||
import { Module } from '@/interfaces';
|
||||
import { toast } from 'react-toastify';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import { ReadingPart } from '@/interfaces/exam';
|
||||
import { LevelPart, ReadingPart } from '@/interfaces/exam';
|
||||
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
|
||||
|
||||
const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
||||
@@ -72,7 +72,7 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
||||
setShowUploaders(false);
|
||||
|
||||
switch (currentModule) {
|
||||
case 'reading':
|
||||
case 'reading': {
|
||||
const newSectionsStates = data.parts.map(
|
||||
(part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part)
|
||||
);
|
||||
@@ -88,9 +88,31 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
||||
}
|
||||
});
|
||||
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) {
|
||||
toast.error(`An unknown error has occured while import ${module} exam!`);
|
||||
toast.error(`Make sure you've imported a valid word document (.docx)!`);
|
||||
} finally {
|
||||
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: false }, module } })
|
||||
}
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import Dropdown from "@/components/Dropdown";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
content: string;
|
||||
open: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
title: string;
|
||||
content: string;
|
||||
open?: boolean;
|
||||
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const Passage: React.FC<Props> = ({ title, content, open, setIsOpen}) => {
|
||||
const paragraphs = content.split('\n\n');
|
||||
const Passage: React.FC<Props> = ({ title, content, open: externalOpen, setIsOpen: externalSetIsOpen }) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
title={title}
|
||||
const isOpen = externalOpen ?? internalOpen;
|
||||
const setIsOpen = externalSetIsOpen ?? setInternalOpen;
|
||||
|
||||
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(
|
||||
"bg-white p-6 w-full items-center",
|
||||
open ? "rounded-t-lg border-b border-gray-200" : "rounded-lg shadow-lg"
|
||||
"text-justify",
|
||||
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"
|
||||
open={open}
|
||||
setIsOpen={setIsOpen}
|
||||
>
|
||||
<div>
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={clsx("text-justify", index < paragraphs.length - 1 ? 'mb-4' : 'mb-6')}
|
||||
>
|
||||
{paragraph.trim()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
>
|
||||
{paragraph.trim()}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default Passage;
|
||||
|
||||
@@ -153,7 +153,7 @@ const ExamEditor: React.FC = () => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 w-1/3">
|
||||
<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 className="flex flex-col gap-3 w-fit h-fit">
|
||||
@@ -171,7 +171,7 @@ const ExamEditor: React.FC = () => {
|
||||
name="label"
|
||||
onChange={(text) => updateModule({ examLabel: text })}
|
||||
roundness="xl"
|
||||
defaultValue={examLabel}
|
||||
value={examLabel}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function InteractiveSpeaking({
|
||||
userSolutions,
|
||||
onNext,
|
||||
onBack,
|
||||
preview = false
|
||||
}: InteractiveSpeakingExercise & CommonProps) {
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
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">
|
||||
Back
|
||||
</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"}
|
||||
</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>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {SpeakingExercise} from "@/interfaces/exam";
|
||||
import {CommonProps} from ".";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
|
||||
import { SpeakingExercise } from "@/interfaces/exam";
|
||||
import { CommonProps } from ".";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs";
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import { downloadBlob } from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
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), {
|
||||
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 [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
@@ -28,7 +28,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
const saveToStorage = async () => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
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.", "");
|
||||
|
||||
@@ -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);
|
||||
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
|
||||
const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config);
|
||||
if (audioURL) await axios.post("/api/storage/delete", { path: audioURL });
|
||||
return response.data.path;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
|
||||
useEffect(() => {
|
||||
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 && !solution.startsWith("blob")) setAudioURL(solution);
|
||||
}
|
||||
@@ -79,8 +79,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
const next = async () => {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||
score: { correct: 0, total: 100, missing: 0 },
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -88,8 +88,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
const back = async () => {
|
||||
onBack({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 0, total: 100, missing: 0},
|
||||
solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [],
|
||||
score: { correct: 0, total: 100, missing: 0 },
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -189,7 +189,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
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">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<div className="flex gap-8 items-center justify-center py-8">
|
||||
@@ -307,9 +307,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">
|
||||
Back
|
||||
</Button>
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
{preview ? (
|
||||
<Button color="purple" isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Props {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
max?: number;
|
||||
min?: number;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export default function Input({
|
||||
className,
|
||||
roundness = "full",
|
||||
disabled = false,
|
||||
min,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -90,7 +92,7 @@ export default function Input({
|
||||
value={value}
|
||||
max={max}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
min={type === "number" ? 0 : undefined}
|
||||
min={type === "number" ? (min ?? 0) : undefined}
|
||||
placeholder={placeholder}
|
||||
className={clsx(
|
||||
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Level from "@/exams/Level";
|
||||
import Listening from "@/exams/Listening";
|
||||
import Reading from "@/exams/Reading";
|
||||
import Speaking from "@/exams/Speaking";
|
||||
import Writing from "@/exams/Writing";
|
||||
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 { usePersistentExamStore } from "@/stores/examStore";
|
||||
import clsx from "clsx";
|
||||
@@ -21,7 +22,7 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
|
||||
state.setPartIndex(0);
|
||||
state.setExerciseIndex(0);
|
||||
state.setQuestionIndex(0);
|
||||
}} showSolutions={true} preview={true} />
|
||||
}} preview={true} />
|
||||
}
|
||||
{state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 &&
|
||||
<Writing exam={state.exam as WritingExam} onFinish={() => {
|
||||
@@ -42,6 +43,11 @@ const Popout: React.FC<{ user: User }> = ({ user }) => {
|
||||
state.setQuestionIndex(0);
|
||||
}} 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>
|
||||
);
|
||||
|
||||
@@ -77,6 +77,15 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !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(() => {
|
||||
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
|
||||
setCurrentExercise(exam.parts[0].exercises[0]);
|
||||
@@ -502,7 +511,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
|
||||
<QuestionsModal isOpen={showQuestionsModal} {...questionModalKwargs} />
|
||||
{
|
||||
!(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) ?
|
||||
<PartDivider
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
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 { ReactNode } from "react";
|
||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
sectionLabel: string;
|
||||
defaultTitle: string;
|
||||
module: Module;
|
||||
section: LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise;
|
||||
section: LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
import {renderExercise} from "@/components/Exercises";
|
||||
import { renderExercise } from "@/components/Exercises";
|
||||
import ModuleTitle from "@/components/Medium/ModuleTitle";
|
||||
import {renderSolution} from "@/components/Solutions";
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {defaultUserSolutions} from "@/utils/exams";
|
||||
import {countExercises} from "@/utils/moduleUtils";
|
||||
import {convertCamelCaseToReadable} from "@/utils/string";
|
||||
import {mdiArrowRight} from "@mdi/js";
|
||||
import { renderSolution } from "@/components/Solutions";
|
||||
import { infoButtonStyle } from "@/constants/buttonStyles";
|
||||
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { defaultUserSolutions } from "@/utils/exams";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import { convertCamelCaseToReadable } from "@/utils/string";
|
||||
import { mdiArrowRight } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import clsx from "clsx";
|
||||
import {Fragment, useEffect, useState} from "react";
|
||||
import {toast} from "react-toastify";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import PartDivider from "./Navigation/SectionDivider";
|
||||
|
||||
interface Props {
|
||||
exam: SpeakingExam;
|
||||
showSolutions?: boolean;
|
||||
onFinish: (userSolutions: UserSolution[]) => void;
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
|
||||
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]);
|
||||
export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) {
|
||||
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]);
|
||||
|
||||
const {userSolutions, setUserSolutions} = useExamStore((state) => state);
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state);
|
||||
const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state);
|
||||
const speakingBgColor = "bg-ielts-speaking-light";
|
||||
|
||||
const examState = 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(() => {
|
||||
if (hasExamEnded && exerciseIndex === -1) {
|
||||
@@ -38,12 +63,12 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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) {
|
||||
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);
|
||||
|
||||
@@ -57,7 +82,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
setHasExamEnded(false);
|
||||
|
||||
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 {
|
||||
onFinish(userSolutions);
|
||||
}
|
||||
@@ -66,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
const previousExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
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) {
|
||||
@@ -85,24 +110,34 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
|
||||
{(showPartDivider) ?
|
||||
<PartDivider
|
||||
module="speaking"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
showSolutions &&
|
||||
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
|
||||
</div>
|
||||
sectionLabel="Speaking"
|
||||
defaultTitle="Speaking exam"
|
||||
section={exam.exercises[exerciseIndex]}
|
||||
sectionIndex={exerciseIndex}
|
||||
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
|
||||
/> : (
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,7 +196,6 @@ export interface SpeakingExercise extends Section {
|
||||
evaluation?: SpeakingEvaluation;
|
||||
}[];
|
||||
topic?: string;
|
||||
script?: Script;
|
||||
}
|
||||
|
||||
export interface InteractiveSpeakingExercise extends Section {
|
||||
@@ -216,7 +215,6 @@ export interface InteractiveSpeakingExercise extends Section {
|
||||
first_topic?: string;
|
||||
second_topic?: string;
|
||||
variant?: "initial" | "final";
|
||||
script?: Script;
|
||||
}
|
||||
|
||||
export interface FillBlanksMCOption {
|
||||
@@ -307,6 +305,10 @@ export interface MultipleChoiceExercise {
|
||||
questions: MultipleChoiceQuestion[];
|
||||
userSolutions: { question: string; option: string }[];
|
||||
mcVariant?: string;
|
||||
passage?: {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MultipleChoiceQuestion {
|
||||
|
||||
21
src/pages/api/exam/avatars.ts
Normal file
21
src/pages/api/exam/avatars.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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 result = await axios.get(`${process.env.BACKEND_URL}/speaking/avatars`, {
|
||||
headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` },
|
||||
});
|
||||
res.status(200).json(result.data);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
const queryParams = queryToURLSearchParams(req);
|
||||
let endpoint = queryParams.getAll('module').join("/");
|
||||
if (endpoint.startsWith("level")) {
|
||||
endpoint = "level"
|
||||
endpoint = "level/"
|
||||
}
|
||||
|
||||
const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`,
|
||||
|
||||
59
src/pages/api/exam/media/[...module].ts
Normal file
59
src/pages/api/exam/media/[...module].ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { sessionOptions } from "@/lib/session";
|
||||
import axios from "axios";
|
||||
import queryToURLSearchParams from "@/utils/query.to.url.params";
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "POST") return post(req, res);
|
||||
|
||||
return res.status(404).json({ ok: false });
|
||||
}
|
||||
|
||||
async function post(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) return res.status(401).json({ ok: false });
|
||||
|
||||
const queryParams = queryToURLSearchParams(req);
|
||||
let endpoint = queryParams.getAll('module').join("/");
|
||||
|
||||
if (endpoint === "listening") {
|
||||
const response = await axios.post(
|
||||
`${process.env.BACKEND_URL}/${endpoint}/media`,
|
||||
req.body,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
|
||||
Accept: 'audio/mpeg'
|
||||
},
|
||||
responseType: 'arraybuffer',
|
||||
}
|
||||
);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Content-Length': response.data.length
|
||||
});
|
||||
|
||||
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."});
|
||||
}
|
||||
26
src/pages/api/exam/media/poll.ts
Normal file
26
src/pages/api/exam/media/poll.ts
Normal 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);
|
||||
}
|
||||
68
src/pages/api/storage/index.ts
Normal file
68
src/pages/api/storage/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
import { IncomingForm, Files, Fields } from 'formidable';
|
||||
import { promises as fs } from 'fs';
|
||||
import { withIronSessionApiRoute } from 'iron-session/next';
|
||||
import { storage } from '@/firebase';
|
||||
import { sessionOptions } from '@/lib/session';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||
|
||||
export async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ error: 'Not authorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Not allowed' });
|
||||
}
|
||||
|
||||
const directory = (req.query.directory as string) || "uploads";
|
||||
|
||||
try {
|
||||
const form = new IncomingForm({
|
||||
keepExtensions: true,
|
||||
multiples: true,
|
||||
});
|
||||
|
||||
const [fields, files]: [Fields, Files] = await new Promise((resolve, reject) => {
|
||||
form.parse(req, (err, fields, files) => {
|
||||
if (err) reject(err);
|
||||
resolve([fields, files]);
|
||||
});
|
||||
});
|
||||
|
||||
const fileArray = files.file;
|
||||
if (!fileArray) {
|
||||
return res.status(400).json({ error: 'No files provided' });
|
||||
}
|
||||
|
||||
const filesToProcess = Array.isArray(fileArray) ? fileArray : [fileArray];
|
||||
|
||||
const uploadPromises = filesToProcess.map(async (file) => {
|
||||
const split = file.originalFilename?.split('.') || ["bin"];
|
||||
const extension = split[split.length - 1];
|
||||
|
||||
const buffer = await fs.readFile(file.filepath);
|
||||
const storageRef = ref(storage, `${directory}/${v4()}.${extension}`);
|
||||
await uploadBytes(storageRef, buffer);
|
||||
const downloadURL = await getDownloadURL(storageRef);
|
||||
await fs.unlink(file.filepath);
|
||||
return downloadURL;
|
||||
});
|
||||
|
||||
const urls = await Promise.all(uploadPromises);
|
||||
res.status(200).json({ urls });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({ error: 'Upload failed' });
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,11 @@ import { mapBy, redirect, serialize } from "@/utils";
|
||||
import { requestUser } from "@/utils/api";
|
||||
import { Module } from "@/interfaces";
|
||||
import { getExam, getExams } from "@/utils/exams.be";
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { Exam, Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { useEffect } from "react";
|
||||
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||
import { isAdmin } from "@/utils/users";
|
||||
import axios from "axios";
|
||||
|
||||
type Permission = { [key in Module]: boolean }
|
||||
|
||||
@@ -60,12 +61,68 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, query })
|
||||
}, sessionOptions);
|
||||
|
||||
export default function Generation({ user, exam, permissions }: { user: User; exam?: Exam, permissions: Permission }) {
|
||||
const { title, currentModule, dispatch } = useExamEditorStore();
|
||||
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
||||
|
||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
||||
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAvatars = async () => {
|
||||
const response = await axios.get("/api/exam/avatars");
|
||||
updateRoot({ speakingAvatars: response.data });
|
||||
};
|
||||
|
||||
fetchAvatars();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// media cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const state = modules;
|
||||
state.listening.sections.forEach(section => {
|
||||
const listeningPart = section.state as ListeningPart;
|
||||
if (listeningPart.audio?.source) {
|
||||
URL.revokeObjectURL(listeningPart.audio.source);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId: section.sectionId, module: "listening", field: "state", value: { ...listeningPart, audio: undefined }
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
state.speaking.sections.forEach(section => {
|
||||
const sectionState = section.state as Exercise;
|
||||
if (sectionState.type === 'speaking') {
|
||||
const speakingExercise = sectionState as SpeakingExercise;
|
||||
URL.revokeObjectURL(speakingExercise.video_url);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId: section.sectionId, module: "listening", field: "state", value: { ...speakingExercise, video_url: undefined }
|
||||
}
|
||||
})
|
||||
}
|
||||
if (sectionState.type === 'interactiveSpeaking') {
|
||||
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
||||
interactiveSpeaking.prompts.forEach(prompt => {
|
||||
URL.revokeObjectURL(prompt.video_url);
|
||||
});
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId: section.sectionId, module: "listening", field: "state", value: {
|
||||
...interactiveSpeaking, prompts: interactiveSpeaking.prompts.map((p) => ({ ...p, video_url: undefined }))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
dispatch({type: 'FULL_RESET'});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (exam) { }
|
||||
}, [exam])
|
||||
@@ -84,7 +141,7 @@ export default function Generation({ user, exam, permissions }: { user: User; ex
|
||||
<ToastContainer />
|
||||
{user && (
|
||||
<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">
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
@@ -26,7 +26,13 @@ const defaultSettings = (module: Module) => {
|
||||
case 'listening':
|
||||
return {
|
||||
...baseSettings,
|
||||
isAudioContextOpen: false
|
||||
isAudioContextOpen: false,
|
||||
isAudioGenerationOpen: false,
|
||||
}
|
||||
case 'speaking':
|
||||
return {
|
||||
...baseSettings,
|
||||
isGenerateAudioOpen: false
|
||||
}
|
||||
default:
|
||||
return baseSettings;
|
||||
|
||||
@@ -11,6 +11,7 @@ const useExamEditorStore = create<
|
||||
title: "",
|
||||
globalEdit: [],
|
||||
currentModule: "reading",
|
||||
speakingAvatars: [],
|
||||
modules: {
|
||||
reading: defaultModuleSettings("reading", 60),
|
||||
writing: defaultModuleSettings("writing", 60),
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import defaultModuleSettings from "../defaults";
|
||||
import ExamEditorStore from "../types";
|
||||
import { MODULE_ACTIONS, ModuleActions, moduleReducer } from "./moduleReducer";
|
||||
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 = (
|
||||
state: ExamEditorStore,
|
||||
action: Action
|
||||
): Partial<ExamEditorStore> => {
|
||||
console.log(action.type);
|
||||
|
||||
if (MODULE_ACTIONS.includes(action.type as any)) {
|
||||
if (action.type === "REORDER_EXERCISES") {
|
||||
const updatedState = sectionReducer(state, action as SectionActions);
|
||||
if (!updatedState.modules) return state;
|
||||
if (!updatedState.modules) return state;
|
||||
|
||||
return moduleReducer({
|
||||
...state,
|
||||
@@ -45,6 +51,19 @@ export const rootReducer = (
|
||||
...state,
|
||||
...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:
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -3,13 +3,6 @@ import { ModuleState } from "../types";
|
||||
import ReorderResult from "./types";
|
||||
|
||||
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
|
||||
.sort((a, b) => parseInt(a.id) - parseInt(b.id))
|
||||
.reduce((acc, solution, index) => {
|
||||
@@ -17,20 +10,29 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
|
||||
return acc;
|
||||
}, {} 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;
|
||||
Object.entries(idMapping).forEach(([oldId, newId]) => {
|
||||
const regex = new RegExp(`\\{\\{${oldId}\\}\\}`, 'g');
|
||||
newText = newText.replace(regex, `{{${newId}}}`);
|
||||
});
|
||||
|
||||
|
||||
let newWords = exercise.words.map(word => {
|
||||
if (typeof word === 'string') {
|
||||
return word;
|
||||
} else if ('letter' in word && 'word' in word) {
|
||||
return word;
|
||||
} else if ('options' in word) {
|
||||
return word;
|
||||
} else if ('options' in word && 'id' in word) {
|
||||
return {
|
||||
...word,
|
||||
id: idMapping[word.id] || word.id
|
||||
};
|
||||
}
|
||||
return word;
|
||||
});
|
||||
@@ -50,7 +52,6 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord
|
||||
},
|
||||
lastId: startId + newSolutions.length
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult<WriteBlanksExercise> => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SectionSettings {
|
||||
|
||||
export interface SpeakingSectionSettings extends SectionSettings {
|
||||
secondTopic?: string;
|
||||
isGenerateAudioOpen: boolean;
|
||||
}
|
||||
|
||||
export interface ReadingSectionSettings extends SectionSettings {
|
||||
@@ -30,6 +31,14 @@ export interface ReadingSectionSettings extends SectionSettings {
|
||||
|
||||
export interface ListeningSectionSettings extends SectionSettings {
|
||||
isAudioContextOpen: boolean;
|
||||
isAudioGenerationOpen: boolean;
|
||||
}
|
||||
|
||||
export interface LevelSectionSettings extends SectionSettings {
|
||||
readingDropdownOpen: boolean;
|
||||
writingDropdownOpen: boolean;
|
||||
speakingDropdownOpen: boolean;
|
||||
listeningDropdownOpen: boolean;
|
||||
}
|
||||
|
||||
export type Generating = "context" | "exercises" | "media" | undefined;
|
||||
@@ -61,9 +70,15 @@ export interface ModuleState {
|
||||
edit: number[];
|
||||
}
|
||||
|
||||
export interface Avatar {
|
||||
name: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
export default interface ExamEditorStore {
|
||||
title: string;
|
||||
currentModule: Module;
|
||||
speakingAvatars: Avatar[];
|
||||
modules: {
|
||||
[K in Module]: ModuleState
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user