diff --git a/src/components/ExamEditor/SettingsEditor/listening/components.tsx b/src/components/ExamEditor/SettingsEditor/listening/components.tsx index 9b57a934..d7b03297 100644 --- a/src/components/ExamEditor/SettingsEditor/listening/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/components.tsx @@ -30,7 +30,6 @@ const ListeningComponents: React.FC = ({ currentSection, localSettings, u const { focusedSection, difficulty, - sections } = useExamEditorStore(state => state.modules[currentModule]); const [originalAudioUrl, setOriginalAudioUrl] = useState(); diff --git a/src/components/ExamEditor/SettingsEditor/listening/index.tsx b/src/components/ExamEditor/SettingsEditor/listening/index.tsx index ceaa8176..e3c4d416 100644 --- a/src/components/ExamEditor/SettingsEditor/listening/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/index.tsx @@ -27,6 +27,7 @@ const ListeningSettings: React.FC = () => { sections, minTimer, isPrivate, + instructionsState } = useExamEditorStore(state => state.modules[currentModule]); const { @@ -71,10 +72,33 @@ const ListeningSettings: React.FC = () => { try { const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source); + if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) { + toast.error("Generate the custom instructions audio first!"); + return; + } + if (sectionsWithAudio.length > 0) { + let instructionsURL = instructionsState.currentInstructionsURL; + + if (instructionsState.chosenOption.value === "Custom") { + const instructionsFormData = new FormData(); + const instructionsResponse = await fetch(instructionsState.currentInstructionsURL); + const instructionsBlob = await instructionsResponse.blob(); + instructionsFormData.append('file', instructionsBlob, 'audio.mp3'); + + const instructionsUploadResponse = await axios.post('/api/storage', instructionsFormData, { + params: { + directory: 'listening_instructions' + }, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + instructionsURL = instructionsUploadResponse.data.urls[0]; + } + const formData = new FormData(); const sectionMap = new Map(); - await Promise.all( sectionsWithAudio.map(async (section) => { const listeningPart = section.state as ListeningPart; @@ -120,6 +144,7 @@ const ListeningSettings: React.FC = () => { variant: sections.length === 4 ? "full" : "partial", difficulty, private: isPrivate, + instructions: instructionsURL }; const result = await axios.post('/api/exam/listening', exam); @@ -140,6 +165,11 @@ const ListeningSettings: React.FC = () => { const preview = () => { + if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) { + toast.error("Generate the custom instructions audio first!"); + return; + } + setExam({ parts: sections.map((s) => { const exercise = s.state as ListeningPart; @@ -156,6 +186,7 @@ const ListeningSettings: React.FC = () => { variant: sections.length === 4 ? "full" : "partial", difficulty, private: isPrivate, + instructions: instructionsState.currentInstructionsURL } as ListeningExam); setExerciseIndex(0); setQuestionIndex(0); diff --git a/src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx b/src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx new file mode 100644 index 00000000..557ee2b3 --- /dev/null +++ b/src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx @@ -0,0 +1,297 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Button from "@/components/Low/Button"; +import Modal from "@/components/Modal"; +import useExamEditorStore from "@/stores/examEditor"; +import { PRESETS, isValidPresetID } from "./presets"; +import AudioPlayer from "@/components/Low/AudioPlayer"; +import Select from "@/components/Low/Select"; +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import { ListeningInstructionsState } from "@/stores/examEditor/types"; +import { debounce } from "lodash"; +import axios from "axios"; +import { toast } from "react-toastify"; +import { playSound } from "@/utils/sound"; +import { BsArrowRepeat } from "react-icons/bs"; +import { GiBrain } from "react-icons/gi"; + +const ListeningInstructions: React.FC = () => { + const { dispatch } = useExamEditorStore(); + const [loading, setLoading] = useState(false); + const { instructionsState: globalInstructions, sections } = useExamEditorStore(s => s.modules["listening"]); + const [localInstructions, setLocalInstructions] = useState(globalInstructions); + const pendingUpdatesRef = useRef>({}); + + useEffect(() => { + if (globalInstructions) { + setLocalInstructions(globalInstructions); + } + }, [globalInstructions]); + + const debouncedUpdateGlobal = useMemo(() => { + return debounce(() => { + if (Object.keys(pendingUpdatesRef.current).length > 0) { + dispatch({ + type: "UPDATE_MODULE", + payload: { + module: "listening", + updates: { + instructionsState: { + ...globalInstructions, + ...pendingUpdatesRef.current + } + } + } + }); + pendingUpdatesRef.current = {}; + } + }, 1000); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, globalInstructions]); + + const updateInstructionsAndSchedule = useCallback(( + updates: Partial | ((prev: ListeningInstructionsState) => Partial), + schedule: boolean = true + ) => { + const newUpdates = typeof updates === 'function' ? updates(localInstructions) : updates; + + setLocalInstructions(prev => ({ + ...prev, + ...newUpdates + })); + + if (schedule) { + pendingUpdatesRef.current = { + ...pendingUpdatesRef.current, + ...newUpdates + }; + debouncedUpdateGlobal(); + } else { + dispatch({ + type: "UPDATE_MODULE", + payload: { + module: "listening", + updates: { + instructionsState: { + ...globalInstructions, + ...newUpdates + } + } + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, debouncedUpdateGlobal]); + + const setIsOpen = useCallback((isOpen: boolean) => { + updateInstructionsAndSchedule({ isInstructionsOpen: isOpen }, false); + }, [updateInstructionsAndSchedule]); + + const onOptionChange = useCallback((option: { value: string, label: string }) => { + const sectionIds = sections.map(s => s.sectionId); + const presetID = [...sectionIds].sort((a, b) => a - b).join('_'); + const preset = isValidPresetID(presetID) ? PRESETS[presetID] : null; + + updateInstructionsAndSchedule(prev => { + const updates: Partial = { + chosenOption: option + }; + + if (option.value === "Automatic" && preset) { + updates.currentInstructions = preset.text; + updates.currentInstructionsURL = preset.url; + } else if (option.value === "Custom") { + updates.currentInstructions = prev.customInstructions || ""; + updates.currentInstructionsURL = ""; + } + + return updates; + }, false); + }, [sections, updateInstructionsAndSchedule]); + + const onCustomInstructionChange = useCallback((text: string) => { + updateInstructionsAndSchedule({ + chosenOption: { value: 'Custom', label: 'Custom' }, + customInstructions: text, + currentInstructions: text + }); + }, [updateInstructionsAndSchedule]); + + useEffect(() => { + const sectionIds = sections.map(s => s.sectionId); + const presetID = [...sectionIds].sort((a, b) => a - b).join('_'); + + if (isValidPresetID(presetID)) { + const preset = PRESETS[presetID]; + updateInstructionsAndSchedule(prev => { + const updates: Partial = { + presetInstructions: preset.text, + presetInstructionsURL: preset.url, + }; + + if (prev.chosenOption?.value === "Automatic") { + updates.currentInstructions = preset.text; + updates.currentInstructionsURL = preset.url; + } + + return updates; + }, false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections.length]); + + useEffect(() => { + return () => { + if (Object.keys(pendingUpdatesRef.current).length > 0) { + dispatch({ + type: "UPDATE_MODULE", + payload: { + module: "listening", + updates: { + instructionsState: { + ...globalInstructions, + ...pendingUpdatesRef.current + } + } + } + }); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch]); + + const options = [ + { value: 'Automatic', label: 'Automatic' }, + { value: 'Custom', label: 'Custom' } + ]; + + const generateInstructionsMP3 = useCallback(async () => { + if (!localInstructions.currentInstructions) { + toast.error('Please enter instructions text first'); + return; + } + + setLoading(true); + + try { + const response = await axios.post( + '/api/exam/media/instructions', + { + text: localInstructions.currentInstructions + }, + { + responseType: 'arraybuffer', + headers: { + 'Accept': 'audio/mpeg' + } + } + ); + + if (localInstructions.currentInstructionsURL?.startsWith('blob:')) { + URL.revokeObjectURL(localInstructions.currentInstructionsURL); + } + + const blob = new Blob([response.data], { type: 'audio/mpeg' }); + const url = URL.createObjectURL(blob); + + updateInstructionsAndSchedule({ + customInstructionsURL: url, + currentInstructionsURL: url + }, false); + + playSound("check"); + toast.success('Audio generated successfully!'); + } catch (error: any) { + toast.error('Failed to generate audio'); + } finally { + setLoading(false); + } + }, [localInstructions.currentInstructions, localInstructions.currentInstructionsURL, updateInstructionsAndSchedule]); + + + return ( + <> + setIsOpen(false)} + > +
+
+

Listening Instructions

+

Choose instruction type or customize your own

+
+ +
+
+ +