From 50481a836e90e4ab4b6a5acf4ebf4bcd716d8fb2 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Thu, 7 Nov 2024 11:06:33 +0000 Subject: [PATCH] Now only when the user submits the listening exam are the mp3 are uploaded onto firebase bucket --- .../ExamEditor/Exercises/Script/index.tsx | 26 ++- .../SectionContext/listening.tsx | 49 +++-- .../SettingsEditor/Shared/GenerateBtn.tsx | 6 +- .../Shared/SettingsDropdown.tsx | 5 +- .../ExamEditor/SettingsEditor/listening.tsx | 179 ++++++++++++++---- .../Shared/ExercisePicker/index.tsx | 15 +- src/pages/api/exam/media/[...module].ts | 40 ++++ src/pages/api/storage/index.ts | 68 +++++++ src/pages/generation.tsx | 57 +++++- src/stores/examEditor/defaults.ts | 3 +- src/stores/examEditor/types.ts | 1 + 11 files changed, 373 insertions(+), 76 deletions(-) create mode 100644 src/pages/api/exam/media/[...module].ts create mode 100644 src/pages/api/storage/index.ts diff --git a/src/components/ExamEditor/Exercises/Script/index.tsx b/src/components/ExamEditor/Exercises/Script/index.tsx index 3fc586a1..76064c8f 100644 --- a/src/components/ExamEditor/Exercises/Script/index.tsx +++ b/src/components/ExamEditor/Exercises/Script/index.tsx @@ -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 = ({ 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(''); const [newMessage, setNewMessage] = useState(''); - const speakerCount = section === 1 ? 2 : 4; - const [speakers, setSpeakers] = useState(() => { + 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(); const speakerGenders = new Map(); @@ -226,7 +244,7 @@ const ScriptEditor: React.FC = ({ section, editing = false, local, setLoc
{editing && ( -
+

Edit Conversation

{speakers.map((speaker, index) => ( diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx index 032c89a4..19bb40b7 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/listening.tsx @@ -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 ( @@ -50,20 +53,38 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => { ); } - return ( - - - + return ( + <> + + {listeningPart.audio?.source && ( + + )} + + + Conversation +
+ } + > + + + ); }; diff --git a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx index dfa664b4..31fab026 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx @@ -10,9 +10,10 @@ interface Props { sectionId: number; genType: Generating; generateFnc: (sectionId: number) => void + className?: string; } -const GenerateBtn: React.FC = ({module, sectionId, genType, generateFnc}) => { +const GenerateBtn: React.FC = ({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 = ({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)} > diff --git a/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx b/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx index 5d3c4964..4f0c0949 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx @@ -9,9 +9,10 @@ interface Props { disabled?: boolean; setIsOpen: (isOpen: boolean) => void; children: ReactNode; + center?: boolean; } -const SettingsDropdown: React.FC = ({ module, title, open, setIsOpen, children, disabled = false }) => { +const SettingsDropdown: React.FC = ({ module, title, open, setIsOpen, children, disabled = false, center = false}) => { return ( = ({ 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} diff --git a/src/components/ExamEditor/SettingsEditor/listening.tsx b/src/components/ExamEditor/SettingsEditor/listening.tsx index e8de38b5..6be5d20e 100644 --- a/src/components/ExamEditor/SettingsEditor/listening.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening.tsx @@ -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(); + + 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} /> - {/* + 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 > - - */} + ); }; diff --git a/src/components/ExamEditor/Shared/ExercisePicker/index.tsx b/src/components/ExamEditor/Shared/ExercisePicker/index.tsx index 33e3cb8e..446df9cd 100644 --- a/src/components/ExamEditor/Shared/ExercisePicker/index.tsx +++ b/src/components/ExamEditor/Shared/ExercisePicker/index.tsx @@ -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 = ({ 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 = ({ 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 = ({ onClick={() => setPickerOpen(true)} disabled={selectedExercises.length == 0} > - Set Up Exercises ({selectedExercises.length}) + {section.generating === "exercises" ? ( +
+ +
+ ) : ( + <>Set Up Exercises ({selectedExercises.length}) + )}
diff --git a/src/pages/api/exam/media/[...module].ts b/src/pages/api/exam/media/[...module].ts new file mode 100644 index 00000000..254ae27e --- /dev/null +++ b/src/pages/api/exam/media/[...module].ts @@ -0,0 +1,40 @@ +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("/"); + + 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); +} diff --git a/src/pages/api/storage/index.ts b/src/pages/api/storage/index.ts new file mode 100644 index 00000000..2fbd1fa6 --- /dev/null +++ b/src/pages/api/storage/index.ts @@ -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' }); + } +} \ No newline at end of file diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index 5d37c93f..3c1acfd4 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -18,8 +18,11 @@ import ExamEditor from "@/components/ExamEditor"; import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit"; import { redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; +import { useEffect } from "react"; +import { Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam"; +import { type } from "os"; -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) if (!user) return redirect("/login") @@ -27,16 +30,56 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { return redirect("/") return { - props: serialize({user}), + props: serialize({ user }), }; }, sessionOptions); export default function Generation({ user }: { user: User; }) { - const { title, currentModule, dispatch } = useExamEditorStore(); + const { title, currentModule, modules, dispatch } = useExamEditorStore(); const updateRoot = (updates: Partial) => { - dispatch({ type: 'UPDATE_ROOT', payload: { updates } }); - }; + dispatch({ type: 'UPDATE_ROOT', payload: { updates } }); + }; + + // 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})) + } + }}) + } + }); + + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( @@ -60,7 +103,7 @@ export default function Generation({ user }: { user: User; }) { placeholder="Insert a title here" name="title" label="Title" - onChange={(title) => updateRoot({title})} + onChange={(title) => updateRoot({ title })} roundness="xl" defaultValue={title} required @@ -69,7 +112,7 @@ export default function Generation({ user }: { user: User; }) { updateRoot({currentModule})} + onChange={(currentModule) => updateRoot({ currentModule })} className="flex flex-row -2xl:flex-wrap w-full gap-4 -md:justify-center justify-between"> {[...MODULE_ARRAY].map((x) => ( diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index d27513c9..fb12e09d 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -26,7 +26,8 @@ const defaultSettings = (module: Module) => { case 'listening': return { ...baseSettings, - isAudioContextOpen: false + isAudioContextOpen: false, + isAudioGenerationOpen: false, } default: return baseSettings; diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index 6ddc3e87..b0a171fb 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -30,6 +30,7 @@ export interface ReadingSectionSettings extends SectionSettings { export interface ListeningSectionSettings extends SectionSettings { isAudioContextOpen: boolean; + isAudioGenerationOpen: boolean; } export type Generating = "context" | "exercises" | "media" | undefined;