From 50481a836e90e4ab4b6a5acf4ebf4bcd716d8fb2 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Thu, 7 Nov 2024 11:06:33 +0000 Subject: [PATCH 1/4] 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; From c507eae507f4be60b7f9d67e9be32c1093661610 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sat, 9 Nov 2024 06:53:17 +0000 Subject: [PATCH 2/4] Forgot to stage it --- .../Speaking/InteractiveSpeaking.tsx | 222 ++++++++++++++ .../Exercises/Speaking/Speaking1.tsx | 280 ++++++++++++++++++ .../Exercises/Speaking/Speaking2.tsx | 280 ++++++++++++++++++ .../ExamEditor/Exercises/Speaking/index.tsx | 177 +---------- .../ExamEditor/SettingsEditor/listening.tsx | 7 +- .../ExamEditor/SettingsEditor/speaking.tsx | 35 ++- src/interfaces/exam.ts | 2 - src/pages/api/exam/avatars.ts | 21 ++ src/pages/generation.tsx | 39 ++- src/stores/examEditor/defaults.ts | 5 + src/stores/examEditor/index.ts | 1 + src/stores/examEditor/types.ts | 7 + 12 files changed, 887 insertions(+), 189 deletions(-) create mode 100644 src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx create mode 100644 src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx create mode 100644 src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx create mode 100644 src/pages/api/exam/avatars.ts diff --git a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx new file mode 100644 index 00000000..83d948d6 --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx @@ -0,0 +1,222 @@ +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { BiQuestionMark } from 'react-icons/bi'; +import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai'; +import { Tooltip } from "react-tooltip"; +import Header from "../../Shared/Header"; +import GenLoader from "../Shared/GenLoader"; +import { useEffect, useState } from "react"; +import useSectionEdit from "../../Hooks/useSectionEdit"; +import useExamEditorStore from "@/stores/examEditor"; +import { InteractiveSpeakingExercise } from "@/interfaces/exam"; +import { BsFileText } from "react-icons/bs"; + +interface Props { + sectionId: number; + exercise: InteractiveSpeakingExercise; +} + +const InteractiveSpeaking: React.FC = ({ sectionId, exercise }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const [local, setLocal] = useState(exercise); + + const { generating, genResult } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); + }, + onDiscard: () => { + setLocal(exercise); + }, + onMode: () => { }, + }); + + useEffect(() => { + if (genResult && generating === "context") { + setEditing(true); + setLocal({ + ...local, + title: genResult[0].title, + prompts: genResult[0].prompts.map((item: any) => ({ + text: item || "", + video_url: "" + })) + }); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + const addPrompt = () => { + setLocal(prev => ({ + ...prev, + prompts: [...prev.prompts, { text: "", video_url: "" }] + })); + }; + + const removePrompt = (index: number) => { + setLocal(prev => ({ + ...prev, + prompts: prev.prompts.filter((_, i) => i !== index) + })); + }; + + const updatePrompt = (index: number, text: string) => { + setLocal(prev => { + const newPrompts = [...prev.prompts]; + newPrompts[index] = { ...newPrompts[index], text }; + return { ...prev, prompts: newPrompts }; + }); + }; + + const isUnedited = local.prompts.length === 0; + + return ( + <> +
+
+
+ {generating ? ( + + ) : ( + <> + {editing ? ( + <> + + +
+

Title

+ setLocal(prev => ({ ...prev, title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the title" + /> +
+
+
+ + +
+

Questions

+
+ +
+ {local.prompts.length === 0 ? ( +
+

No questions added yet

+
+ ) : ( + local.prompts.map((prompt, index) => ( + + +
+
+

Question {index + 1}

+ +
+ updatePrompt(index, text)} + className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white" + placeholder={`Enter question ${index + 1}`} + /> +
+
+
+ )) + )} +
+ +
+ +
+
+
+ + ) : isUnedited ? ( +

+ Generate or edit the questions! +

+ ) : ( +
+ + +
+
+ +

Title

+
+
+

{local.title || 'Untitled'}

+
+
+
+
+ + +
+
+ +

Questions

+
+
+ {local.prompts + .filter(prompt => prompt.text !== "") + .map((prompt, index) => ( +
+

Question {index + 1}

+

{prompt.text}

+
+ )) + } +
+
+
+
+
+ )} + + )} + + ); +}; + +export default InteractiveSpeaking; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx new file mode 100644 index 00000000..33ffb90d --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx @@ -0,0 +1,280 @@ +import React, { useEffect, useState } from 'react'; +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai'; +import Header from "../../Shared/Header"; +import GenLoader from "../Shared/GenLoader"; +import useSectionEdit from "../../Hooks/useSectionEdit"; +import useExamEditorStore from "@/stores/examEditor"; +import { InteractiveSpeakingExercise } from "@/interfaces/exam"; +import { BsFileText } from "react-icons/bs"; + +interface Props { + sectionId: number; + exercise: InteractiveSpeakingExercise; +} + +const Speaking1: React.FC = ({ sectionId, exercise }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const [local, setLocal] = useState(() => { + const defaultPrompts = [ + { text: "Hello my name is {avatar}, what is yours?", video_url: "" }, + { text: "Do you work or do you study?", video_url: "" }, + ...exercise.prompts.slice(2) + ]; + return { ...exercise, prompts: defaultPrompts }; + }); + + const updateAvatarName = (avatarName: string) => { + setLocal(prev => { + const updatedPrompts = [...prev.prompts]; + updatedPrompts[0] = { + ...updatedPrompts[0], + text: updatedPrompts[0].text.replace("{avatar}", avatarName) + }; + return { ...prev, prompts: updatedPrompts }; + }); + }; + + const { generating, genResult } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); + }, + onDiscard: () => { + setLocal({ + ...exercise, + prompts: [ + { text: "Hello my name is {avatar}, what is yours?", video_url: "" }, + { text: "Do you work or do you study?", video_url: "" }, + ...exercise.prompts.slice(2) + ] + }); + }, + onMode: () => { }, + }); + + useEffect(() => { + if (genResult && generating === "context") { + setEditing(true); + setLocal(prev => ({ + ...prev, + first_title: genResult[0].first_topic, + second_title: genResult[0].second_topic, + prompts: [ + prev.prompts[0], + prev.prompts[1], + ...genResult[0].prompts.map((item: any) => ({ + text: item, + video_url: "" + })) + ] + })); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + const addPrompt = () => { + setLocal(prev => ({ + ...prev, + prompts: [...prev.prompts, { text: "", video_url: "" }] + })); + }; + + const removePrompt = (index: number) => { + if (index < 2) return; + setLocal(prev => ({ + ...prev, + prompts: prev.prompts.filter((_, i) => i !== index) + })); + }; + + const updatePrompt = (index: number, text: string) => { + if (index < 2) return; + setLocal(prev => { + const newPrompts = [...prev.prompts]; + newPrompts[index] = { ...newPrompts[index], text }; + return { ...prev, prompts: newPrompts }; + }); + }; + + const isUnedited = local.prompts.length === 2; + + return ( + <> +
+
+
+ {generating ? ( + + ) : ( + <> + {editing ? ( + <> + + +
+
+ +

Titles

+
+
+
+

First Title

+ setLocal(prev => ({ ...prev, first_title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the first title" + /> +
+
+

Second Title

+ setLocal(prev => ({ ...prev, second_title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the second title" + /> +
+
+
+
+
+ + +
+

Questions

+
+ +
+ {local.prompts.length === 2 ? ( +
+

No questions added yet

+
+ ) : ( + local.prompts.slice(2).map((prompt, index) => ( + + +
+
+

Question {index + 1}

+ +
+ updatePrompt(index + 2, text)} + className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white" + placeholder={`Enter question ${index + 1}`} + /> +
+
+
+ )) + )} +
+ +
+ +
+
+
+ + ) : isUnedited ? ( +

+ Generate or edit the questions! +

+ ) : ( +
+ + +
+
+ +

Titles

+
+
+
+

First Title

+
+

{local.first_title || 'No first title'}

+
+
+
+

Second Title

+
+

{local.second_title || 'No second title'}

+
+
+
+
+
+
+ + +
+
+ +

Questions

+
+
+ {local.prompts.slice(2) + .filter(prompt => prompt.text !== "") + .map((prompt, index) => ( +
+

Question {index + 1}

+

{prompt.text}

+
+ )) + } +
+
+
+
+
+ )} + + )} + + ); +}; + +export default Speaking1; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx new file mode 100644 index 00000000..1a5df28f --- /dev/null +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx @@ -0,0 +1,280 @@ +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import { Card, CardContent } from "@/components/ui/card"; +import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai'; +import { SpeakingExercise } from "@/interfaces/exam"; +import useExamEditorStore from "@/stores/examEditor"; +import { useEffect, useState } from "react"; +import useSectionEdit from "../../Hooks/useSectionEdit"; +import Header from "../../Shared/Header"; +import { Tooltip } from "react-tooltip"; +import { BsFileText } from 'react-icons/bs'; +import { AiOutlineUnorderedList } from 'react-icons/ai'; +import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi"; +import GenLoader from "../Shared/GenLoader"; + +interface Props { + sectionId: number; + exercise: SpeakingExercise; +} + +const Speaking2: React.FC = ({ sectionId, exercise }) => { + const { currentModule, dispatch } = useExamEditorStore(); + const [local, setLocal] = useState(exercise); + + const { generating, genResult } = useExamEditorStore( + (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! + ); + + + const updateTopic = (topic: string) => { + setLocal(prev => ({ ...prev, topic: topic })); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { topic: topic } } }); + }; + + + const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + sectionId, + mode: "edit", + onSave: () => { + setEditing(false); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); + }, + onDiscard: () => { + setLocal(exercise); + }, + onMode: () => { }, + }); + + + useEffect(() => { + if (genResult && generating === "context") { + setEditing(true); + setLocal({ + ...local, + title: genResult[0].topic, + text: genResult[0].question, + prompts: genResult[0].prompts + }); + + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + const addPrompt = () => { + setLocal(prev => ({ + ...prev, + prompts: [...prev.prompts, ""] + })); + }; + + const removePrompt = (index: number) => { + setLocal(prev => ({ + ...prev, + prompts: prev.prompts.filter((_, i) => i !== index) + })); + }; + + const updatePrompt = (index: number, text: string) => { + setLocal(prev => { + const newPrompts = [...prev.prompts]; + newPrompts[index] = text; + return { ...prev, prompts: newPrompts }; + }); + }; + + const isUnedited = local.text === "" || + (local.title === undefined || local.title === "") || + local.prompts.length === 0; + + const tooltipContent = ` +
+

+ Prompts are guiding points that help candidates structure their talk. They typically include aspects like: +

    +
  • Describing what/who/where
  • +
  • Explaining why
  • +
  • Sharing feelings or preferences
  • +
+

+
+ `; + + return ( + <> +
+
+
+ {generating ? ( + + ) : ( + <> + {editing ? ( + <> + + +
+

Title

+ setLocal(prev => ({ ...prev, title: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the topic" + /> +
+
+
+ + +
+

Question

+ setLocal(prev => ({ ...prev, text: text }))} + className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" + placeholder="Enter the main question" + /> +
+
+
+ + +
+

Prompts

+ + + + +
+ +
+ {local.prompts.length === 0 ? ( +
+

No prompts added yet

+
+ ) : ( + local.prompts.map((prompt, index) => ( + + +
+
+

Prompt {index + 1}

+ +
+ updatePrompt(index, text)} + className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white" + placeholder={`Enter prompt ${index + 1}`} + /> +
+
+
+ )) + )} +
+ +
+ +
+
+
+ + ) : isUnedited ? ( +

+ Generate or edit the script! +

+ ) : ( +
+ + +
+
+ +

Title

+
+
+

{local.title || 'Untitled'}

+
+
+
+
+ + +
+
+ +

Question

+
+
+

{local.text || 'No question provided'}

+
+
+
+
+ {local.prompts && local.prompts.length > 0 && ( + + +
+
+ +

Prompts

+
+
+
+ {local.prompts.map((prompt, index) => ( +
+

{prompt}

+
+ ))} +
+
+
+
+
+ )} +
+ )} + + )} + + ); +} + +export default Speaking2; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Speaking/index.tsx b/src/components/ExamEditor/Exercises/Speaking/index.tsx index 0dea9f87..eed5a2a7 100644 --- a/src/components/ExamEditor/Exercises/Speaking/index.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/index.tsx @@ -7,6 +7,9 @@ import Header from "../../Shared/Header"; import GenLoader from "../Shared/GenLoader"; import { Card, CardContent } from "@/components/ui/card"; import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import Speaking2 from "./Speaking2"; +import InteractiveSpeaking from "./InteractiveSpeaking"; +import Speaking1 from "./Speaking1"; interface Props { sectionId: number; @@ -14,176 +17,24 @@ interface Props { } const Speaking: React.FC = ({ sectionId, exercise }) => { - const { currentModule, dispatch } = useExamEditorStore(); - const { generating, genResult } = useExamEditorStore( + const { currentModule } = useExamEditorStore(); + const { state } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); - const { edit } = useExamEditorStore((store) => store.modules[currentModule]); - - const [local, setLocal] = useState(exercise); - const [loading, setLoading] = useState(generating === "context"); - const [questions, setQuestions] = useState(() => { - if (sectionId === 1) { - if ((exercise as SpeakingExercise).prompts.length > 0) { - return (exercise as SpeakingExercise).prompts; - } - return Array(5).fill(""); - } else if (sectionId === 2) { - if ((exercise as SpeakingExercise).text && (exercise as SpeakingExercise).prompts.length > 0) { - return (exercise as SpeakingExercise).prompts; - } - return Array(3).fill(""); - } else { - if ((exercise as InteractiveSpeakingExercise).prompts.length > 0) { - return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text); - } - return Array(5).fill(""); - } - }); - - const updateModule = useCallback((updates: Partial) => { - dispatch({ type: 'UPDATE_MODULE', payload: { updates } }); - }, [dispatch]); - - const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ - sectionId, - mode: "edit", - onSave: () => { - let newExercise; - if (sectionId === 1) { - newExercise = { - ...local, - prompts: questions - } as SpeakingExercise; - } else if (sectionId === 2) { - newExercise = { - ...local, - text: questions[0], - prompts: questions.slice(1), - } as SpeakingExercise; - } else { - newExercise = { - ...local, - prompts: questions.map(text => ({ - text, - video_url: (local as InteractiveSpeakingExercise).prompts?.[0]?.video_url || "" - })) - } as InteractiveSpeakingExercise; - } - setEditing(false); - dispatch({ - type: "UPDATE_SECTION_STATE", - payload: { - sectionId: sectionId, - update: newExercise - } - }); - }, - onDiscard: () => { - setLocal(exercise); - }, - onMode: () => { - setLocal(exercise); - if (sectionId === 1) { - setQuestions(Array(5).fill("")); - } else if (sectionId === 2) { - setQuestions(Array(3).fill("")); - } else { - setQuestions(Array(5).fill("")); - } - }, - }); - - useEffect(() => { - const isLoading = generating === "context"; - setLoading(isLoading); - - if (isLoading) { - updateModule({ edit: Array.from(new Set([...edit, sectionId])) }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [generating, sectionId]); - - useEffect(() => { - if (genResult && generating === "context") { - setEditing(true); - if (sectionId === 1) { - setQuestions(genResult[0].questions); - } else if (sectionId === 2) { - setQuestions([genResult[0].question, ...genResult[0].prompts]); - } else { - setQuestions(genResult[0].questions); - } - - dispatch({ - type: "UPDATE_SECTION_SINGLE_FIELD", - payload: { - sectionId, - module: currentModule, - field: "genResult", - value: undefined - } - }); - } - }, [genResult, generating, dispatch, sectionId, setEditing, currentModule]); - - const handleQuestionChange = (index: number, value: string) => { - setQuestions(prev => { - const newQuestions = [...prev]; - newQuestions[index] = value; - return newQuestions; - }); - }; - - const getQuestionLabel = (index: number) => { - if (sectionId === 2 && index === 0) { - return "Main Question"; - } else if (sectionId === 2) { - return `Prompt ${index}`; - } else { - return `Question ${index + 1}`; - } - }; return ( <> -
-
-
- {loading ? ( - - ) : ( -
- - -
-
- {questions.map((question: string, index: number) => ( -
-

{getQuestionLabel(index)}

- handleQuestionChange(index, text)} - className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all" - placeholder={`Enter ${getQuestionLabel(index).toLowerCase()}`} - /> -
- ))} -
-
-
-
+
+
+
+ {sectionId === 1 && } + {sectionId === 2 && } + {sectionId === 3 && } +
- )} +
); }; -export default Speaking; +export default Speaking; \ No newline at end of file diff --git a/src/components/ExamEditor/SettingsEditor/listening.tsx b/src/components/ExamEditor/SettingsEditor/listening.tsx index 6be5d20e..884130e1 100644 --- a/src/components/ExamEditor/SettingsEditor/listening.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening.tsx @@ -19,7 +19,6 @@ import { toast } from "react-toastify"; const ListeningSettings: React.FC = () => { const router = useRouter(); - const [audioLoading, setAudioLoading] = useState(false); const { currentModule, title, dispatch } = useExamEditorStore(); const { focusedSection, @@ -187,7 +186,7 @@ const ListeningSettings: React.FC = () => { } try { - setAudioLoading(true); + dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: "media"}}); const response = await axios.post( '/api/exam/media/listening', body, @@ -223,7 +222,7 @@ const ListeningSettings: React.FC = () => { } catch (error: any) { toast.error('Failed to generate audio'); } finally { - setAudioLoading(false); + dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: undefined}}); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -303,7 +302,7 @@ const ListeningSettings: React.FC = () => { > { - const { currentModule, dispatch } = useExamEditorStore(); + const { currentModule } = useExamEditorStore(); const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule]) const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( @@ -33,7 +33,6 @@ const SpeakingSettings: React.FC = () => { ]; const generateScript = useCallback((sectionId: number) => { - const queryParams: { difficulty: string; first_topic?: string; @@ -57,7 +56,7 @@ const SpeakingSettings: React.FC = () => { generate( sectionId, currentModule, - "context", + "context", // <- not really context but exercises is reserved for reading, listening and level { method: 'GET', queryParams @@ -66,9 +65,9 @@ const SpeakingSettings: React.FC = () => { switch (sectionId) { case 1: return [{ - questions: data.questions, - firstTopic: data.first_topic, - secondTopic: data.second_topic + prompts: data.questions, + first_topic: data.first_topic, + second_topic: data.second_topic }]; case 2: return [{ @@ -79,8 +78,8 @@ const SpeakingSettings: React.FC = () => { }]; case 3: return [{ - topic: data.topic, - questions: data.questions + title: data.topic, + prompts: data.questions }]; default: return [data]; @@ -95,7 +94,7 @@ const SpeakingSettings: React.FC = () => { }, [updateLocalAndScheduleGlobal]); const onSecondTopicChange = useCallback((topic: string) => { - updateLocalAndScheduleGlobal({ }); + updateLocalAndScheduleGlobal({ secondTopic: topic }); }, [updateLocalAndScheduleGlobal]); return ( @@ -153,6 +152,24 @@ const SpeakingSettings: React.FC = () => {
+ updateLocalAndScheduleGlobal({ isGenerateAudio: isOpen }, false)} + > +
+ +
+ +
+
+
); }; diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 0a5e3df9..a79212ff 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -196,7 +196,6 @@ export interface SpeakingExercise extends Section { evaluation?: SpeakingEvaluation; }[]; topic?: string; - script?: Script; } export interface InteractiveSpeakingExercise extends Section { @@ -216,7 +215,6 @@ export interface InteractiveSpeakingExercise extends Section { first_topic?: string; second_topic?: string; variant?: "initial" | "final"; - script?: Script; } export interface FillBlanksMCOption { diff --git a/src/pages/api/exam/avatars.ts b/src/pages/api/exam/avatars.ts new file mode 100644 index 00000000..1a87a7fb --- /dev/null +++ b/src/pages/api/exam/avatars.ts @@ -0,0 +1,21 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import axios from "axios"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return get(req, res); + + return res.status(404).json({ ok: false }); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }); + + const result = await axios.get(`${process.env.BACKEND_URL}/speaking/avatars`, { + headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` }, + }); + res.status(200).json(result.data); +} diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index 3c1acfd4..b4d92559 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -20,7 +20,7 @@ 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"; +import axios from "axios"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) @@ -41,6 +41,17 @@ export default function Generation({ user }: { user: User; }) { dispatch({ type: 'UPDATE_ROOT', payload: { updates } }); }; + useEffect(() => { + const fetchAvatars = async () => { + const response = await axios.get("/api/exam/avatars"); + console.log(response.data); + updateRoot({ speakingAvatars: response.data }); + }; + + fetchAvatars(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // media cleanup on unmount useEffect(() => { return () => { @@ -49,9 +60,11 @@ export default function Generation({ user }: { user: User; }) { 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} - }}) + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId: section.sectionId, module: "listening", field: "state", value: { ...listeningPart, audio: undefined } + } + }) } }); @@ -60,20 +73,24 @@ export default function Generation({ user }: { user: User; }) { 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} - }}) + 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})) + 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 })) + } } - }}) + }) } }); diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index fb12e09d..690e007a 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -29,6 +29,11 @@ const defaultSettings = (module: Module) => { isAudioContextOpen: false, isAudioGenerationOpen: false, } + case 'speaking': + return { + ...baseSettings, + isGenerateAudio: false + } default: return baseSettings; } diff --git a/src/stores/examEditor/index.ts b/src/stores/examEditor/index.ts index 845b7a40..7db76092 100644 --- a/src/stores/examEditor/index.ts +++ b/src/stores/examEditor/index.ts @@ -11,6 +11,7 @@ const useExamEditorStore = create< title: "", globalEdit: [], currentModule: "reading", + speakingAvatars: [], modules: { reading: defaultModuleSettings("reading", 60), writing: defaultModuleSettings("writing", 60), diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index b0a171fb..a97a377d 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -22,6 +22,7 @@ export interface SectionSettings { export interface SpeakingSectionSettings extends SectionSettings { secondTopic?: string; + isGenerateAudio: boolean; } export interface ReadingSectionSettings extends SectionSettings { @@ -62,9 +63,15 @@ export interface ModuleState { edit: number[]; } +export interface Avatar { + name: string; + gender: string; +} + export default interface ExamEditorStore { title: string; currentModule: Module; + speakingAvatars: Avatar[]; modules: { [K in Module]: ModuleState }; From 322d7905c3ade720cb1a8daedf9bc24cef46082c Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 10 Nov 2024 04:24:23 +0000 Subject: [PATCH 3/4] Reverted Level to only utas placement test exercises, Speaking, bug fixes, placeholder --- .../MultipleChoice/Underline/index.tsx | 1 + .../MultipleChoice/Vanilla/index.tsx | 2 + .../Speaking/InteractiveSpeaking.tsx | 95 ++++- .../Exercises/Speaking/Speaking1.tsx | 108 +++++- .../Exercises/Speaking/Speaking2.tsx | 63 ++- .../SectionContext/reading.tsx | 3 +- .../SectionExercises/index.tsx | 2 +- .../SectionExercises/level.tsx | 22 +- .../SettingsEditor/Shared/Generate.ts | 2 + .../SettingsEditor/Shared/GenerateBtn.tsx | 7 +- .../SettingsEditor/Shared/generateVideos.ts | 125 ++++++ .../ExamEditor/SettingsEditor/index.tsx | 1 + .../ExamEditor/SettingsEditor/level.tsx | 117 ++++-- .../ExamEditor/SettingsEditor/listening.tsx | 40 +- .../ExamEditor/SettingsEditor/reading.tsx | 31 +- .../ExamEditor/SettingsEditor/speaking.tsx | 360 ++++++++++++++++-- .../Shared/ExercisePicker/ExerciseWizard.tsx | 43 ++- .../Shared/ExercisePicker/exercises.ts | 37 +- .../ExercisePicker/generatedExercises.ts | 2 +- .../Shared/ExercisePicker/index.tsx | 26 +- .../Shared/ImportExam/WordUploader.tsx | 26 +- src/components/ExamEditor/Shared/Passage.tsx | 65 ++-- src/components/ExamEditor/index.tsx | 4 +- .../Exercises/InteractiveSpeaking.tsx | 9 +- src/components/Exercises/Speaking.tsx | 46 ++- src/components/Low/Input.tsx | 4 +- src/components/Popouts/Exam.tsx | 10 +- src/exams/Level/index.tsx | 13 +- src/exams/Navigation/SectionDivider.tsx | 4 +- src/exams/Speaking.tsx | 111 ++++-- src/interfaces/exam.ts | 4 + src/pages/api/exam/generate/[...module].ts | 2 +- src/pages/api/exam/media/[...module].ts | 53 ++- src/pages/api/exam/media/poll.ts | 26 ++ src/pages/generation.tsx | 5 +- src/stores/examEditor/defaults.ts | 2 +- src/stores/examEditor/reducers/index.ts | 27 +- src/stores/examEditor/reorder/global.ts | 23 +- src/stores/examEditor/types.ts | 9 +- 39 files changed, 1251 insertions(+), 279 deletions(-) create mode 100644 src/components/ExamEditor/SettingsEditor/Shared/generateVideos.ts create mode 100644 src/pages/api/exam/media/poll.ts diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx index 0b2114cc..76f02b3f 100644 --- a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx @@ -88,6 +88,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); }, onDiscard: () => { + setAlerts([]); setLocal(exercise); setEditing(false); }, diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx index 76e1c396..2f9c9670 100644 --- a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx @@ -167,6 +167,8 @@ const MultipleChoice: React.FC = ({ exercise, sectionId, op dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); }, onDiscard: () => { + setEditing(false); + setAlerts([]); setLocal(exercise); }, onMode: () => { diff --git a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx index 83d948d6..ac86f827 100644 --- a/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/InteractiveSpeaking.tsx @@ -10,6 +10,8 @@ import useSectionEdit from "../../Hooks/useSectionEdit"; import useExamEditorStore from "@/stores/examEditor"; import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { BsFileText } from "react-icons/bs"; +import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; +import { RiVideoLine } from "react-icons/ri"; interface Props { sectionId: number; @@ -20,6 +22,8 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise }) => { const { currentModule, dispatch } = useExamEditorStore(); const [local, setLocal] = useState(exercise); + const [currentVideoIndex, setCurrentVideoIndex] = useState(0); + const { generating, genResult } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); @@ -86,6 +90,42 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise }) => { const isUnedited = local.prompts.length === 0; + useEffect(() => { + if (genResult && generating === "media") { + setLocal({ ...local, prompts: genResult[0].prompts }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "generating", + value: undefined + } + }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + const handlePrevVideo = () => { + setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev)); + }; + + const handleNextVideo = () => { + setCurrentVideoIndex((prev) => + (prev < local.prompts.length - 1 ? prev + 1 : prev) + ); + }; + return ( <>
@@ -100,12 +140,65 @@ const InteractiveSpeaking: React.FC = ({ sectionId, exercise }) => { module="speaking" />
- {generating ? ( + {generating && generating === "context" ? ( ) : ( <> {editing ? ( <> + {local.prompts.every((p) => p.video_url !== "") && ( + + +
+
+
+ +

Videos

+
+
+ + + {currentVideoIndex + 1} / {local.prompts.length} + + +
+
+
+
+ +
+
+
+
+
+ )} + {generating && generating === "media" && + + }
diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx index 33ffb90d..9cb8fe93 100644 --- a/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx @@ -8,6 +8,8 @@ import useSectionEdit from "../../Hooks/useSectionEdit"; import useExamEditorStore from "@/stores/examEditor"; import { InteractiveSpeakingExercise } from "@/interfaces/exam"; import { BsFileText } from "react-icons/bs"; +import { RiVideoLine } from 'react-icons/ri'; +import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'; interface Props { sectionId: number; @@ -25,16 +27,7 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { return { ...exercise, prompts: defaultPrompts }; }); - const updateAvatarName = (avatarName: string) => { - setLocal(prev => { - const updatedPrompts = [...prev.prompts]; - updatedPrompts[0] = { - ...updatedPrompts[0], - text: updatedPrompts[0].text.replace("{avatar}", avatarName) - }; - return { ...prev, prompts: updatedPrompts }; - }); - }; + const [currentVideoIndex, setCurrentVideoIndex] = useState(0); const { generating, genResult } = useExamEditorStore( (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! @@ -87,7 +80,7 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { } }); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [genResult, generating]); const addPrompt = () => { @@ -116,6 +109,44 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { const isUnedited = local.prompts.length === 2; + + useEffect(() => { + if (genResult && generating === "media") { + console.log(genResult[0].prompts); + setLocal({ ...local, prompts: genResult[0].prompts }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "generating", + value: undefined + } + }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + + const handlePrevVideo = () => { + setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev)); + }; + + const handleNextVideo = () => { + setCurrentVideoIndex((prev) => + (prev < local.prompts.length - 1 ? prev + 1 : prev) + ); + }; + return ( <>
@@ -130,7 +161,7 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => { module="speaking" />
- {generating ? ( + {generating && generating === "context" ? ( ) : ( <> @@ -224,6 +255,59 @@ const Speaking1: React.FC = ({ sectionId, exercise }) => {

) : (
+ {local.prompts.every((p) => p.video_url !== "") && ( + + +
+
+
+ +

Videos

+
+
+ + + {currentVideoIndex + 1} / {local.prompts.length} + + +
+
+
+
+ +
+
+
+
+
+ )} + {generating && generating === "media" && + + }
diff --git a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx index 1a5df28f..1b506e5f 100644 --- a/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx +++ b/src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx @@ -11,12 +11,14 @@ import { BsFileText } from 'react-icons/bs'; import { AiOutlineUnorderedList } from 'react-icons/ai'; import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi"; import GenLoader from "../Shared/GenLoader"; +import { RiVideoLine } from 'react-icons/ri'; interface Props { sectionId: number; exercise: SpeakingExercise; } + const Speaking2: React.FC = ({ sectionId, exercise }) => { const { currentModule, dispatch } = useExamEditorStore(); const [local, setLocal] = useState(exercise); @@ -25,18 +27,12 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { (state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! ); - - const updateTopic = (topic: string) => { - setLocal(prev => ({ ...prev, topic: topic })); - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { topic: topic } } }); - }; - - const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ sectionId, mode: "edit", onSave: () => { setEditing(false); + console.log(local); dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } }); }, onDiscard: () => { @@ -69,6 +65,32 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [genResult, generating]); + useEffect(() => { + if (genResult && generating === "media") { + setLocal({...local, video_url: genResult[0].video_url}); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult[0].video_url} } }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "generating", + value: undefined + } + }); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: currentModule, + field: "genResult", + value: undefined + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [genResult, generating]); + const addPrompt = () => { setLocal(prev => ({ ...prev, @@ -91,9 +113,9 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { }); }; - const isUnedited = local.text === "" || - (local.title === undefined || local.title === "") || - local.prompts.length === 0; + const isUnedited = local.text === "" || + (local.title === undefined || local.title === "") || + local.prompts.length === 0; const tooltipContent = `
@@ -122,7 +144,7 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => { module="speaking" />
- {generating ? ( + {generating && generating === "context" ? ( ) : ( <> @@ -222,6 +244,25 @@ const Speaking2: React.FC = ({ sectionId, exercise }) => {

) : (
+ {local.video_url && + +
+
+ +

Video

+
+
+ +
+
+
+
+ } + {generating && generating === "media" && + + }
diff --git a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx index 5c5276ac..7c357d7a 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionContext/reading.tsx @@ -41,8 +41,9 @@ const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => { useEffect(()=> { if (genResult !== undefined && generating === "context") { setEditing(true); + console.log(genResult); setTitle(genResult[0].title); - setContent(genResult[0].text) + setContent(genResult[0].text); dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "genResult", value: undefined}}) } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx index 7f40e22e..b41c4929 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/index.tsx @@ -131,7 +131,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => { collisionDetection={closestCenter} onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId } })} > - {(currentModule === "level" && questions.ids?.length === 0) ? ( + {(currentModule === "level" && questions.ids?.length === 0 && generating === undefined) ? ( background(Generated exercises will appear here!) ) : ( expandedSections.includes(sectionId) && diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx index 8d40da04..120d1ea9 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx @@ -3,6 +3,7 @@ import ExerciseItem, { isExerciseItem } from "./types"; import ExerciseLabel from "../../Shared/ExerciseLabel"; import MultipleChoice from "../../Exercises/MultipleChoice"; import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice"; +import Passage from "../../Shared/Passage"; const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => { @@ -14,6 +15,19 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci let firstWordId, lastWordId; switch (exercise.type) { case "multipleChoice": + let content = ; + const isReadingPassage = exercise.mcVariant && exercise.mcVariant === "passageUtas"; + if (isReadingPassage) { + content = (<> +
+ +
+ + ); + } firstWordId = exercise.questions[0].id; lastWordId = exercise.questions[exercise.questions.length - 1].id; return { @@ -21,7 +35,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci sectionId, label: ( "{previewLabel(exercise.prompt)}..." @@ -29,7 +43,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci } /> ), - content: + content }; case "fillBlanks": firstWordId = exercise.solutions[0].id; @@ -39,7 +53,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci sectionId, label: ( "{previewLabel(exercise.prompt)}..." @@ -54,7 +68,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci } }).filter(isExerciseItem); - return items; + return items; }; diff --git a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts index e1f09bda..0fe09d76 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts +++ b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts @@ -42,6 +42,8 @@ export function generate( const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; + console.log(config.body); + const request = config.method === 'POST' ? axios.post(url, config.body) : axios.get(url); diff --git a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx index 31fab026..cdc3b36e 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx @@ -14,7 +14,12 @@ interface Props { } const GenerateBtn: React.FC = ({module, sectionId, genType, generateFnc, className}) => { - const {generating} = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId))!; + const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId)); + if (section === undefined) return; + + const {generating} = section; + + const loading = generating && generating === genType; return ( - + ) : ( + + )}
diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 12e942d0..7875a626 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -1,20 +1,20 @@ -import {SpeakingExercise} from "@/interfaces/exam"; -import {CommonProps} from "."; -import {Fragment, useEffect, useState} from "react"; -import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; +import { SpeakingExercise } from "@/interfaces/exam"; +import { CommonProps } from "."; +import { Fragment, useEffect, useState } from "react"; +import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; import dynamic from "next/dynamic"; import Button from "../Low/Button"; import useExamStore from "@/stores/examStore"; -import {downloadBlob} from "@/utils/evaluation"; +import { downloadBlob } from "@/utils/evaluation"; import axios from "axios"; import Modal from "../Modal"; -const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); +const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { ssr: false, }); -export default function Speaking({id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { +export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack, preview = false }: SpeakingExercise & CommonProps) { const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); const [mediaBlob, setMediaBlob] = useState(); @@ -28,7 +28,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf const saveToStorage = async () => { if (mediaBlob && mediaBlob.startsWith("blob")) { const blobBuffer = await downloadBlob(mediaBlob); - const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"}); + const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" }); const seed = Math.random().toString().replace("0.", ""); @@ -42,8 +42,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf }, }; - const response = await axios.post<{path: string}>("/api/storage/insert", formData, config); - if (audioURL) await axios.post("/api/storage/delete", {path: audioURL}); + const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config); + if (audioURL) await axios.post("/api/storage/delete", { path: audioURL }); return response.data.path; } @@ -52,7 +52,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf useEffect(() => { if (userSolutions.length > 0) { - const {solution} = userSolutions[0] as {solution?: string}; + const { solution } = userSolutions[0] as { solution?: string }; if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution); } @@ -79,8 +79,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf const next = async () => { onNext({ exercise: id, - solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], - score: {correct: 0, total: 100, missing: 0}, + solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], + score: { correct: 0, total: 100, missing: 0 }, type, }); }; @@ -88,8 +88,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf const back = async () => { onBack({ exercise: id, - solutions: mediaBlob ? [{id, solution: mediaBlob}] : [], - score: {correct: 0, total: 100, missing: 0}, + solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], + score: { correct: 0, total: 100, missing: 0 }, type, }); }; @@ -115,6 +115,8 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf } }; + console.log(preview); + return (
@@ -189,7 +191,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf setMediaBlob(blob)} - render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( + render={({ status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl }) => (

Record your answer:

@@ -307,9 +309,15 @@ export default function Speaking({id, title, text, video_url, type, prompts, suf - + {preview ? ( + + ) : ( + + )}
diff --git a/src/components/Low/Input.tsx b/src/components/Low/Input.tsx index ae1b8710..e2c26a27 100644 --- a/src/components/Low/Input.tsx +++ b/src/components/Low/Input.tsx @@ -12,6 +12,7 @@ interface Props { className?: string; disabled?: boolean; max?: number; + min?: number; name: string; onChange: (value: string) => void; } @@ -28,6 +29,7 @@ export default function Input({ className, roundness = "full", disabled = false, + min, onChange, }: Props) { const [showPassword, setShowPassword] = useState(false); @@ -90,7 +92,7 @@ export default function Input({ value={value} max={max} onChange={(e) => onChange(e.target.value)} - min={type === "number" ? 0 : undefined} + min={type === "number" ? (min ?? 0) : undefined} placeholder={placeholder} className={clsx( "px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", diff --git a/src/components/Popouts/Exam.tsx b/src/components/Popouts/Exam.tsx index 8d8b2d8f..980fcae7 100644 --- a/src/components/Popouts/Exam.tsx +++ b/src/components/Popouts/Exam.tsx @@ -1,9 +1,10 @@ import Level from "@/exams/Level"; import Listening from "@/exams/Listening"; import Reading from "@/exams/Reading"; +import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; import { usePersistentStorage } from "@/hooks/usePersistentStorage"; -import { LevelExam, ListeningExam, ReadingExam, WritingExam } from "@/interfaces/exam"; +import { LevelExam, ListeningExam, ReadingExam, SpeakingExam, WritingExam } from "@/interfaces/exam"; import { User } from "@/interfaces/user"; import { usePersistentExamStore } from "@/stores/examStore"; import clsx from "clsx"; @@ -21,7 +22,7 @@ const Popout: React.FC<{ user: User }> = ({ user }) => { state.setPartIndex(0); state.setExerciseIndex(0); state.setQuestionIndex(0); - }} showSolutions={true} preview={true} /> + }} preview={true} /> } {state.exam?.module == "writing" && state.exam.exercises && state.partIndex >= 0 && { @@ -42,6 +43,11 @@ const Popout: React.FC<{ user: User }> = ({ user }) => { state.setQuestionIndex(0); }} preview={true} /> } + {state.exam?.module == "speaking" && state.exam.exercises.length > 0 && + { + state.setExerciseIndex(-1); + }} preview={true} /> + }
); diff --git a/src/exams/Level/index.tsx b/src/exams/Level/index.tsx index ef35f4e3..3038b617 100644 --- a/src/exams/Level/index.tsx +++ b/src/exams/Level/index.tsx @@ -75,6 +75,15 @@ export default function Level({ exam, showSolutions = false, onFinish, preview = const [showPartDivider, setShowPartDivider] = useState(typeof exam.parts[0].intro === "string" && !showSolutions); const [startNow, setStartNow] = useState(!showSolutions); + + useEffect(() => { + if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { + setShowPartDivider(true); + setBgColor(levelBgColor); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exerciseIndex]); + useEffect(() => { if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) { setCurrentExercise(exam.parts[0].exercises[0]); @@ -462,7 +471,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview = { !(partIndex === 0 && questionIndex === 0 && (showPartDivider || startNow)) && - + } {(showPartDivider || startNow) ? { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); }} /> : ( <> - {exam.parts[0].intro && ( + {exam.parts[0].intro && exam.parts.length !== 1 && ( void; } diff --git a/src/exams/Speaking.tsx b/src/exams/Speaking.tsx index 07c7352f..6b124ae3 100644 --- a/src/exams/Speaking.tsx +++ b/src/exams/Speaking.tsx @@ -1,31 +1,56 @@ -import {renderExercise} from "@/components/Exercises"; +import { renderExercise } from "@/components/Exercises"; import ModuleTitle from "@/components/Medium/ModuleTitle"; -import {renderSolution} from "@/components/Solutions"; -import {infoButtonStyle} from "@/constants/buttonStyles"; -import {UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise} from "@/interfaces/exam"; -import useExamStore from "@/stores/examStore"; -import {defaultUserSolutions} from "@/utils/exams"; -import {countExercises} from "@/utils/moduleUtils"; -import {convertCamelCaseToReadable} from "@/utils/string"; -import {mdiArrowRight} from "@mdi/js"; +import { renderSolution } from "@/components/Solutions"; +import { infoButtonStyle } from "@/constants/buttonStyles"; +import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam"; +import useExamStore, { usePersistentExamStore } from "@/stores/examStore"; +import { defaultUserSolutions } from "@/utils/exams"; +import { countExercises } from "@/utils/moduleUtils"; +import { convertCamelCaseToReadable } from "@/utils/string"; +import { mdiArrowRight } from "@mdi/js"; import Icon from "@mdi/react"; import clsx from "clsx"; -import {Fragment, useEffect, useState} from "react"; -import {toast} from "react-toastify"; +import { Fragment, useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import PartDivider from "./Navigation/SectionDivider"; interface Props { exam: SpeakingExam; showSolutions?: boolean; onFinish: (userSolutions: UserSolution[]) => void; + preview?: boolean; } -export default function Speaking({exam, showSolutions = false, onFinish}: Props) { - const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{id: string; amount: number}[]>([]); +export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) { + const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]); - const {userSolutions, setUserSolutions} = useExamStore((state) => state); - const {questionIndex, setQuestionIndex} = useExamStore((state) => state); - const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); - const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); + const speakingBgColor = "bg-ielts-speaking-light"; + + const examState = useExamStore((state) => state); + const persistentExamState = usePersistentExamStore((state) => state); + + const { + userSolutions, + questionIndex, + exerciseIndex, + hasExamEnded, + setBgColor, + setUserSolutions, + setHasExamEnded, + setQuestionIndex, + setExerciseIndex, + } = !preview ? examState : persistentExamState; + + const [seenParts, setSeenParts] = useState>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : [])); + const [showPartDivider, setShowPartDivider] = useState(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== ""); + + useEffect(() => { + if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) { + setShowPartDivider(true); + setBgColor(speakingBgColor); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exerciseIndex]); useEffect(() => { if (hasExamEnded && exerciseIndex === -1) { @@ -38,12 +63,12 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) const nextExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]); } if (questionIndex > 0) { const exercise = getExercise(); - setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), {id: exercise.id, amount: questionIndex}]); + setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: questionIndex }]); } setQuestionIndex(0); @@ -57,7 +82,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) setHasExamEnded(false); if (solution) { - onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); + onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]); } else { onFinish(userSolutions); } @@ -66,7 +91,7 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) const previousExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { - setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "speaking", exam: exam.id}]); + setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]); } if (exerciseIndex > 0) { @@ -85,24 +110,34 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) return ( <> -
- acc + curr.amount, 0)} + {(showPartDivider) ? + - {exerciseIndex > -1 && - exerciseIndex < exam.exercises.length && - !showSolutions && - renderExercise(getExercise(), exam.id, nextExercise, previousExercise)} - {exerciseIndex > -1 && - exerciseIndex < exam.exercises.length && - showSolutions && - renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} -
+ sectionLabel="Speaking" + defaultTitle="Speaking exam" + section={exam.exercises[exerciseIndex]} + sectionIndex={exerciseIndex} + onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }} + /> : ( +
+ acc + curr.amount, 0)} + module="speaking" + totalExercises={countExercises(exam.exercises)} + disableTimer={showSolutions || preview} + /> + {exerciseIndex > -1 && + exerciseIndex < exam.exercises.length && + !showSolutions && + renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)} + {exerciseIndex > -1 && + exerciseIndex < exam.exercises.length && + showSolutions && + renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)} +
+ )} ); } diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index a79212ff..cd7001e6 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -305,6 +305,10 @@ export interface MultipleChoiceExercise { questions: MultipleChoiceQuestion[]; userSolutions: { question: string; option: string }[]; mcVariant?: string; + passage?: { + title: string; + content: string; + } } export interface MultipleChoiceQuestion { diff --git a/src/pages/api/exam/generate/[...module].ts b/src/pages/api/exam/generate/[...module].ts index 71ca7a68..3ed3825e 100644 --- a/src/pages/api/exam/generate/[...module].ts +++ b/src/pages/api/exam/generate/[...module].ts @@ -33,7 +33,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const queryParams = queryToURLSearchParams(req); let endpoint = queryParams.getAll('module').join("/"); if (endpoint.startsWith("level")) { - endpoint = "level" + endpoint = "level/" } const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`, diff --git a/src/pages/api/exam/media/[...module].ts b/src/pages/api/exam/media/[...module].ts index 254ae27e..ac3e95a3 100644 --- a/src/pages/api/exam/media/[...module].ts +++ b/src/pages/api/exam/media/[...module].ts @@ -15,26 +15,45 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { 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', - } - ); + if (endpoint === "listening") { + 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.writeHead(200, { + 'Content-Type': 'audio/mpeg', + 'Content-Length': response.data.length + }); - res.end(response.data); + res.end(response.data); + return; + } + + if (endpoint === "speaking") { + const response = await axios.post( + `${process.env.BACKEND_URL}/${endpoint}/media`, + req.body, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + } + } + ); + + res.status(200).json(response.data); + return; + } + + return res.status(405).json({ "error": "Method not allowed."}); } diff --git a/src/pages/api/exam/media/poll.ts b/src/pages/api/exam/media/poll.ts new file mode 100644 index 00000000..9a98b9d6 --- /dev/null +++ b/src/pages/api/exam/media/poll.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import axios from "axios"; +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return get(req, res); + + return res.status(404).json({ ok: false }); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }); + + const { videoId } = req.query; + const response = await axios.get( + `${process.env.BACKEND_URL}/speaking/media/${videoId}`, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + } + } + ); + return res.status(200).json(response.data); +} diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index b4d92559..add36a9c 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -44,7 +44,6 @@ export default function Generation({ user }: { user: User; }) { useEffect(() => { const fetchAvatars = async () => { const response = await axios.get("/api/exam/avatars"); - console.log(response.data); updateRoot({ speakingAvatars: response.data }); }; @@ -93,7 +92,7 @@ export default function Generation({ user }: { user: User; }) { }) } }); - + dispatch({type: 'FULL_RESET'}); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -113,7 +112,7 @@ export default function Generation({ user }: { user: User; }) { {user && ( -

Exam Generation

+

Exam Editor

{ case 'speaking': return { ...baseSettings, - isGenerateAudio: false + isGenerateAudioOpen: false } default: return baseSettings; diff --git a/src/stores/examEditor/reducers/index.ts b/src/stores/examEditor/reducers/index.ts index 9a84aef3..a229972a 100644 --- a/src/stores/examEditor/reducers/index.ts +++ b/src/stores/examEditor/reducers/index.ts @@ -1,20 +1,26 @@ +import defaultModuleSettings from "../defaults"; import ExamEditorStore from "../types"; import { MODULE_ACTIONS, ModuleActions, moduleReducer } from "./moduleReducer"; import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReducer"; +type UpdateRoot = { + type: 'UPDATE_ROOT'; + payload: { + updates: Partial + } +}; +type FullReset = { type: 'FULL_RESET' }; -export type Action = ModuleActions | SectionActions | { type: 'UPDATE_ROOT'; payload: { updates: Partial } }; +export type Action = ModuleActions | SectionActions | UpdateRoot | FullReset; export const rootReducer = ( state: ExamEditorStore, action: Action ): Partial => { - console.log(action.type); - if (MODULE_ACTIONS.includes(action.type as any)) { if (action.type === "REORDER_EXERCISES") { const updatedState = sectionReducer(state, action as SectionActions); - if (!updatedState.modules) return state; + if (!updatedState.modules) return state; return moduleReducer({ ...state, @@ -45,6 +51,19 @@ export const rootReducer = ( ...state, ...updates }; + case 'FULL_RESET': + return { + title: "", + currentModule: "reading", + speakingAvatars: [], + modules: { + reading: defaultModuleSettings("reading", 60), + writing: defaultModuleSettings("writing", 60), + speaking: defaultModuleSettings("speaking", 14), + listening: defaultModuleSettings("listening", 30), + level: defaultModuleSettings("level", 60) + }, + } default: return {}; } diff --git a/src/stores/examEditor/reorder/global.ts b/src/stores/examEditor/reorder/global.ts index 255ca251..12dbdff9 100644 --- a/src/stores/examEditor/reorder/global.ts +++ b/src/stores/examEditor/reorder/global.ts @@ -3,13 +3,6 @@ import { ModuleState } from "../types"; import ReorderResult from "./types"; const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): ReorderResult => { - let newSolutions = exercise.solutions - .sort((a, b) => parseInt(a.id) - parseInt(b.id)) - .map((solution, index) => ({ - ...solution, - id: (startId + index).toString() - })); - let idMapping = exercise.solutions .sort((a, b) => parseInt(a.id) - parseInt(b.id)) .reduce((acc, solution, index) => { @@ -17,20 +10,29 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord return acc; }, {} as Record); + let newSolutions = exercise.solutions + .sort((a, b) => parseInt(a.id) - parseInt(b.id)) + .map((solution, index) => ({ + ...solution, + id: (startId + index).toString() + })); + let newText = exercise.text; Object.entries(idMapping).forEach(([oldId, newId]) => { const regex = new RegExp(`\\{\\{${oldId}\\}\\}`, 'g'); newText = newText.replace(regex, `{{${newId}}}`); }); - let newWords = exercise.words.map(word => { if (typeof word === 'string') { return word; } else if ('letter' in word && 'word' in word) { return word; - } else if ('options' in word) { - return word; + } else if ('options' in word && 'id' in word) { + return { + ...word, + id: idMapping[word.id] || word.id + }; } return word; }); @@ -50,7 +52,6 @@ const reorderFillBlanks = (exercise: FillBlanksExercise, startId: number): Reord }, lastId: startId + newSolutions.length }; - }; const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): ReorderResult => { diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index a97a377d..77494e66 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -22,7 +22,7 @@ export interface SectionSettings { export interface SpeakingSectionSettings extends SectionSettings { secondTopic?: string; - isGenerateAudio: boolean; + isGenerateAudioOpen: boolean; } export interface ReadingSectionSettings extends SectionSettings { @@ -34,6 +34,13 @@ export interface ListeningSectionSettings extends SectionSettings { isAudioGenerationOpen: boolean; } +export interface LevelSectionSettings extends SectionSettings { + readingDropdownOpen: boolean; + writingDropdownOpen: boolean; + speakingDropdownOpen: boolean; + listeningDropdownOpen: boolean; +} + export type Generating = "context" | "exercises" | "media" | undefined; export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise; export type ExamPart = ListeningPart | ReadingPart | LevelPart; From 421934195139035f4b83d9836ac04f30d21887f2 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Sun, 10 Nov 2024 06:19:19 +0000 Subject: [PATCH 4/4] Leftovers from the previous commit --- .../MatchSentences/ParagraphViewer.tsx | 2 +- .../SectionExercises/reading.tsx | 19 +++++++++++++++++++ .../SectionRenderer/SectionExercises/types.ts | 4 ++-- .../SettingsEditor/Shared/Generate.ts | 2 -- .../SettingsEditor/Shared/GenerateBtn.tsx | 2 +- .../ExamEditor/SettingsEditor/level.tsx | 2 +- .../Shared/ExercisePicker/index.tsx | 2 +- .../Shared/ImportExam/WordUploader.tsx | 2 +- src/components/Exercises/Speaking.tsx | 2 -- 9 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx index cdee19cc..dd9d854d 100644 --- a/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx +++ b/src/components/ExamEditor/Exercises/MatchSentences/ParagraphViewer.tsx @@ -12,7 +12,7 @@ interface Props { const ReferenceViewer: React.FC = ({ showReference, selectedReference, options, setShowReference, headings = true}) => (
diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx b/src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx index 7b31d770..7a36757b 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx @@ -4,6 +4,7 @@ import ExerciseLabel from '../../Shared/ExerciseLabel'; import MatchSentences from '../../Exercises/MatchSentences'; import TrueFalse from '../../Exercises/TrueFalse'; import FillBlanksLetters from '../../Exercises/Blanks/Letters'; +import MultipleChoice from '../../Exercises/MultipleChoice'; const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): ExerciseItem[] => { @@ -87,6 +88,24 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer ), content: }; + case "multipleChoice": + firstWordId = exercise.questions[0].id; + lastWordId = exercise.questions[exercise.questions.length - 1].id; + return { + id: index.toString(), + sectionId, + label: ( + + "{previewLabel(exercise.prompt)}..." + + } + /> + ), + content: + }; } }).filter(isExerciseItem); diff --git a/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts b/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts index 0a06164b..4a29c13b 100644 --- a/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts +++ b/src/components/ExamEditor/SectionRenderer/SectionExercises/types.ts @@ -1,4 +1,4 @@ -import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam"; +import { FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam"; export default interface ExerciseItem { id: string; @@ -7,7 +7,7 @@ export default interface ExerciseItem { content: React.ReactNode; } -export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise; +export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise | MultipleChoiceExercise; export function isExerciseItem(item: unknown): item is ExerciseItem { return item !== undefined && diff --git a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts index 0fe09d76..e1f09bda 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts +++ b/src/components/ExamEditor/SettingsEditor/Shared/Generate.ts @@ -42,8 +42,6 @@ export function generate( const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`; - console.log(config.body); - const request = config.method === 'POST' ? axios.post(url, config.body) : axios.get(url); diff --git a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx index cdc3b36e..9a2f9583 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/GenerateBtn.tsx @@ -15,7 +15,7 @@ interface Props { const GenerateBtn: React.FC = ({module, sectionId, genType, generateFnc, className}) => { const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId)); - if (section === undefined) return; + if (section === undefined) return <>; const {generating} = section; diff --git a/src/components/ExamEditor/SettingsEditor/level.tsx b/src/components/ExamEditor/SettingsEditor/level.tsx index 8a6ca855..ccbfa485 100644 --- a/src/components/ExamEditor/SettingsEditor/level.tsx +++ b/src/components/ExamEditor/SettingsEditor/level.tsx @@ -42,7 +42,7 @@ const LevelSettings: React.FC = () => { ); const section = sections.find((section) => section.sectionId == focusedSection); - if (section === undefined) return; + if (section === undefined) return <>; const currentSection = section.state as LevelPart; diff --git a/src/components/ExamEditor/Shared/ExercisePicker/index.tsx b/src/components/ExamEditor/Shared/ExercisePicker/index.tsx index 8aed1d28..26623dd7 100644 --- a/src/components/ExamEditor/Shared/ExercisePicker/index.tsx +++ b/src/components/ExamEditor/Shared/ExercisePicker/index.tsx @@ -28,7 +28,7 @@ const ExercisePicker: React.FC = ({ const [pickerOpen, setPickerOpen] = useState(false); - if (section === undefined) return; + if (section === undefined) return <>; const { state, selectedExercises } = section; diff --git a/src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx b/src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx index de266eb1..c52a4620 100644 --- a/src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx +++ b/src/components/ExamEditor/Shared/ImportExam/WordUploader.tsx @@ -112,7 +112,7 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => { } } } catch (error) { - toast.error(`An unknown error has occured while import ${module} exam!`); + toast.error(`Make sure you've imported a valid word document (.docx)!`); } finally { dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: false }, module } }) } diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 7875a626..b96714c9 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -115,8 +115,6 @@ export default function Speaking({ id, title, text, video_url, type, prompts, su } }; - console.log(preview); - return (