Now only when the user submits the listening exam are the mp3 are uploaded onto firebase bucket
This commit is contained in:
@@ -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) => (
|
||||
|
||||
@@ -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">Conversation</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ScriptRender
|
||||
local={scriptLocal}
|
||||
setLocal={setScriptLocal}
|
||||
section={sectionId}
|
||||
editing={editing}
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ interface Props {
|
||||
sectionId: number;
|
||||
genType: Generating;
|
||||
generateFnc: (sectionId: number) => void
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc}) => {
|
||||
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => {
|
||||
const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!;
|
||||
const loading = generating && generating === genType;
|
||||
return (
|
||||
@@ -20,7 +21,8 @@ const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc})
|
||||
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}
|
||||
|
||||
@@ -19,13 +19,14 @@ import { toast } from "react-toastify";
|
||||
|
||||
const ListeningSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const {currentModule, title } = useExamEditorStore();
|
||||
const [audioLoading, setAudioLoading] = useState(false);
|
||||
const { currentModule, title, dispatch } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
sections,
|
||||
minTimer,
|
||||
isPrivate
|
||||
isPrivate,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const {
|
||||
@@ -77,39 +78,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: localSettings.currentIntro,
|
||||
category: localSettings.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({
|
||||
@@ -125,7 +168,7 @@ const ListeningSettings: React.FC = () => {
|
||||
module: "listening",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
variant: sections.length === 4 ? "full" : "partial",
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
} as ListeningExam);
|
||||
@@ -135,14 +178,65 @@ const ListeningSettings: React.FC = () => {
|
||||
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 {
|
||||
setAudioLoading(true);
|
||||
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 {
|
||||
setAudioLoading(false);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
@@ -198,22 +292,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.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
|
||||
center
|
||||
>
|
||||
<ExercisePicker
|
||||
module="listening"
|
||||
<GenerateBtn
|
||||
module={currentModule}
|
||||
genType="context"
|
||||
sectionId={focusedSection}
|
||||
selectedExercises={selectedExercises}
|
||||
setSelectedExercises={setSelectedExercises}
|
||||
difficulty={difficulty}
|
||||
generateFnc={generateAudio}
|
||||
className="mb-4"
|
||||
/>
|
||||
</Dropdown>*/}
|
||||
</Dropdown>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,8 +23,8 @@ 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 { difficulty} = useExamEditorStore((store) => store.modules[currentModule]);
|
||||
const section = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId == sectionId)!);
|
||||
const { state, selectedExercises } = section;
|
||||
|
||||
|
||||
@@ -111,7 +112,7 @@ 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);
|
||||
};
|
||||
|
||||
@@ -162,7 +163,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>
|
||||
|
||||
Reference in New Issue
Block a user