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 {
|
interface Props {
|
||||||
section: number;
|
section: number;
|
||||||
editing?: boolean;
|
editing?: boolean;
|
||||||
local: Script;
|
local?: Script;
|
||||||
setLocal: (script: Script) => void;
|
setLocal: (script: Script) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +41,32 @@ const colorOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
|
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
|
||||||
|
|
||||||
const isConversation = [1, 3].includes(section);
|
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 [selectedSpeaker, setSelectedSpeaker] = useState<string>('');
|
||||||
const [newMessage, setNewMessage] = useState('');
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
|
||||||
const speakerCount = section === 1 ? 2 : 4;
|
|
||||||
|
|
||||||
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
|
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 existingScript = local as ScriptLine[];
|
||||||
const existingSpeakers = new Set<string>();
|
const existingSpeakers = new Set<string>();
|
||||||
const speakerGenders = new Map<string, 'male' | 'female'>();
|
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">
|
<CardContent className="py-10">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{editing && (
|
{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>
|
<h3 className="text-lg font-medium text-gray-700 mb-6">Edit Conversation</h3>
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
{speakers.map((speaker, index) => (
|
{speakers.map((speaker, index) => (
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import useSectionEdit from "../../Hooks/useSectionEdit";
|
|||||||
import ScriptRender from "../../Exercises/Script";
|
import ScriptRender from "../../Exercises/Script";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import Dropdown from "@/components/Dropdown";
|
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 }) => {
|
const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||||
@@ -41,7 +44,7 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||||
|
|
||||||
const renderContent = (editing: boolean) => {
|
const renderContent = (editing: boolean) => {
|
||||||
if (scriptLocal === undefined) {
|
if (scriptLocal === undefined && !editing) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-10">
|
<CardContent className="py-10">
|
||||||
@@ -50,12 +53,29 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
|
||||||
|
{listeningPart.audio?.source && (
|
||||||
|
<AudioPlayer
|
||||||
|
key={sectionId}
|
||||||
|
src={listeningPart.audio?.source ?? ''}
|
||||||
|
color="listening"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Dropdown
|
<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`}
|
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"
|
||||||
title="Conversation"
|
contentWrapperClassName="rounded-xl mt-2"
|
||||||
contentWrapperClassName="rounded-xl"
|
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
|
<ScriptRender
|
||||||
local={scriptLocal}
|
local={scriptLocal}
|
||||||
@@ -64,6 +84,7 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
editing={editing}
|
editing={editing}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ interface Props {
|
|||||||
sectionId: number;
|
sectionId: number;
|
||||||
genType: Generating;
|
genType: Generating;
|
||||||
generateFnc: (sectionId: number) => void
|
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 {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!;
|
||||||
const loading = generating && generating === genType;
|
const loading = generating && generating === genType;
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +21,8 @@ const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc})
|
|||||||
key={`section-${sectionId}`}
|
key={`section-${sectionId}`}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg",
|
"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)}
|
onClick={loading ? () => { } : () => generateFnc(sectionId)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
children: ReactNode;
|
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 (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
title={title}
|
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`,
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||||
open ? "rounded-t-lg" : "rounded-lg"
|
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}
|
open={open}
|
||||||
setIsOpen={setIsOpen}
|
setIsOpen={setIsOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ import { toast } from "react-toastify";
|
|||||||
|
|
||||||
const ListeningSettings: React.FC = () => {
|
const ListeningSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {currentModule, title } = useExamEditorStore();
|
const [audioLoading, setAudioLoading] = useState(false);
|
||||||
|
const { currentModule, title, dispatch } = useExamEditorStore();
|
||||||
const {
|
const {
|
||||||
focusedSection,
|
focusedSection,
|
||||||
difficulty,
|
difficulty,
|
||||||
sections,
|
sections,
|
||||||
minTimer,
|
minTimer,
|
||||||
isPrivate
|
isPrivate,
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -77,16 +78,52 @@ const ListeningSettings: React.FC = () => {
|
|||||||
updateLocalAndScheduleGlobal({ topic });
|
updateLocalAndScheduleGlobal({ topic });
|
||||||
}, [updateLocalAndScheduleGlobal]);
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
const submitListening = () => {
|
const submitListening = async () => {
|
||||||
if (title === "") {
|
if (title === "") {
|
||||||
toast.error("Enter a title for the exam!");
|
toast.error("Enter a title for the exam!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source);
|
||||||
|
|
||||||
|
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 = {
|
const exam: ListeningExam = {
|
||||||
parts: sections.map((s) => {
|
parts: sections.map((s) => {
|
||||||
const exercise = s.state as ListeningPart;
|
const exercise = s.state as ListeningPart;
|
||||||
|
const index = Array.from(sectionMap.entries())
|
||||||
|
.findIndex(([id]) => id === s.sectionId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...exercise,
|
...exercise,
|
||||||
|
audio: exercise.audio ? {
|
||||||
|
...exercise.audio,
|
||||||
|
source: index !== -1 ? urls[index] : exercise.audio.source
|
||||||
|
} : undefined,
|
||||||
intro: localSettings.currentIntro,
|
intro: localSettings.currentIntro,
|
||||||
category: localSettings.category
|
category: localSettings.category
|
||||||
};
|
};
|
||||||
@@ -100,17 +137,23 @@ const ListeningSettings: React.FC = () => {
|
|||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
};
|
};
|
||||||
|
|
||||||
axios.post(`/api/exam/listening`, exam)
|
const result = await axios.post('/api/exam/listening', exam);
|
||||||
.then((result) => {
|
|
||||||
playSound("sent");
|
playSound("sent");
|
||||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||||
})
|
|
||||||
.catch((error) => {
|
} else {
|
||||||
console.log(error);
|
toast.error('No audio sections found in the exam! Please either import them or generate them.');
|
||||||
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error submitting exam:', error);
|
||||||
|
toast.error(
|
||||||
|
"Something went wrong while submitting, please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const preview = () => {
|
const preview = () => {
|
||||||
setExam({
|
setExam({
|
||||||
parts: sections.map((s) => {
|
parts: sections.map((s) => {
|
||||||
@@ -125,7 +168,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
module: "listening",
|
module: "listening",
|
||||||
id: title,
|
id: title,
|
||||||
isDiagnostic: false,
|
isDiagnostic: false,
|
||||||
variant: undefined,
|
variant: sections.length === 4 ? "full" : "partial",
|
||||||
difficulty,
|
difficulty,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
} as ListeningExam);
|
} as ListeningExam);
|
||||||
@@ -135,6 +178,57 @@ const ListeningSettings: React.FC = () => {
|
|||||||
openDetachedTab("popout?type=Exam&module=listening", router)
|
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(
|
const canPreview = sections.some(
|
||||||
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
||||||
);
|
);
|
||||||
@@ -198,22 +292,23 @@ const ListeningSettings: React.FC = () => {
|
|||||||
difficulty={difficulty}
|
difficulty={difficulty}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
{/*
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
title="Generate Audio"
|
title="Generate Audio"
|
||||||
module={currentModule}
|
module={currentModule}
|
||||||
open={localSettings.isExerciseDropdownOpen}
|
open={localSettings.isAudioGenerationOpen}
|
||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
|
||||||
disabled={currentSection.script === undefined && currentSection.audio === undefined}
|
disabled={currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
|
||||||
|
center
|
||||||
>
|
>
|
||||||
<ExercisePicker
|
<GenerateBtn
|
||||||
module="listening"
|
module={currentModule}
|
||||||
|
genType="context"
|
||||||
sectionId={focusedSection}
|
sectionId={focusedSection}
|
||||||
selectedExercises={selectedExercises}
|
generateFnc={generateAudio}
|
||||||
setSelectedExercises={setSelectedExercises}
|
className="mb-4"
|
||||||
difficulty={difficulty}
|
|
||||||
/>
|
/>
|
||||||
</Dropdown>*/}
|
</Dropdown>
|
||||||
</SettingsEditor>
|
</SettingsEditor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { generate } from "../../SettingsEditor/Shared/Generate";
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
|
import { ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
|
||||||
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
|
|
||||||
interface ExercisePickerProps {
|
interface ExercisePickerProps {
|
||||||
module: string;
|
module: string;
|
||||||
@@ -22,8 +23,8 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
extraArgs = undefined,
|
extraArgs = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
const { difficulty, sections } = useExamEditorStore((store) => store.modules[currentModule]);
|
const { difficulty} = useExamEditorStore((store) => store.modules[currentModule]);
|
||||||
const section = sections.find((s) => s.sectionId == sectionId)!;
|
const section = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId == sectionId)!);
|
||||||
const { state, selectedExercises } = section;
|
const { state, selectedExercises } = section;
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +163,13 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
disabled={selectedExercises.length == 0}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
40
src/pages/api/exam/media/[...module].ts
Normal file
40
src/pages/api/exam/media/[...module].ts
Normal 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);
|
||||||
|
}
|
||||||
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ import ExamEditor from "@/components/ExamEditor";
|
|||||||
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
|
import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit";
|
||||||
import { redirect, serialize } from "@/utils";
|
import { redirect, serialize } from "@/utils";
|
||||||
import { requestUser } from "@/utils/api";
|
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)
|
const user = await requestUser(req, res)
|
||||||
@@ -32,12 +35,52 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => {
|
|||||||
}, sessionOptions);
|
}, sessionOptions);
|
||||||
|
|
||||||
export default function Generation({ user }: { user: User; }) {
|
export default function Generation({ user }: { user: User; }) {
|
||||||
const { title, currentModule, dispatch } = useExamEditorStore();
|
const { title, currentModule, modules, dispatch } = useExamEditorStore();
|
||||||
|
|
||||||
const updateRoot = (updates: Partial<ExamEditorStore>) => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const defaultSettings = (module: Module) => {
|
|||||||
case 'listening':
|
case 'listening':
|
||||||
return {
|
return {
|
||||||
...baseSettings,
|
...baseSettings,
|
||||||
isAudioContextOpen: false
|
isAudioContextOpen: false,
|
||||||
|
isAudioGenerationOpen: false,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return baseSettings;
|
return baseSettings;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface ReadingSectionSettings extends SectionSettings {
|
|||||||
|
|
||||||
export interface ListeningSectionSettings extends SectionSettings {
|
export interface ListeningSectionSettings extends SectionSettings {
|
||||||
isAudioContextOpen: boolean;
|
isAudioContextOpen: boolean;
|
||||||
|
isAudioGenerationOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Generating = "context" | "exercises" | "media" | undefined;
|
export type Generating = "context" | "exercises" | "media" | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user