Now only when the user submits the listening exam are the mp3 are uploaded onto firebase bucket

This commit is contained in:
Carlos-Mesquita
2024-11-07 11:06:33 +00:00
parent 0741c4c647
commit 50481a836e
11 changed files with 373 additions and 76 deletions

View File

@@ -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) => (

View File

@@ -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>
</>
);
};

View File

@@ -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)}
>

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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);
}

View 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' });
}
}

View File

@@ -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<ExamEditorStore>) => {
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; }) {
<label className="font-normal text-base text-mti-gray-dim">Module</label>
<RadioGroup
value={currentModule}
onChange={(currentModule) => 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) => (
<Radio value={x} key={x}>

View File

@@ -26,7 +26,8 @@ const defaultSettings = (module: Module) => {
case 'listening':
return {
...baseSettings,
isAudioContextOpen: false
isAudioContextOpen: false,
isAudioGenerationOpen: false,
}
default:
return baseSettings;

View File

@@ -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;