From fdf411d133daefd9a13939055a0846e6354e2f66 Mon Sep 17 00:00:00 2001 From: Carlos-Mesquita Date: Tue, 12 Nov 2024 14:17:54 +0000 Subject: [PATCH] ENCOA-228 Now when user navigates between modules the generation items persist. Reading, listening and writing added to level module --- .../ExercisePicker/ExerciseWizard.tsx | 12 +- .../{Shared => }/ExercisePicker/exercises.ts | 12 + .../ExercisePicker/generatedExercises.ts | 0 .../{Shared => }/ExercisePicker/index.tsx | 177 ++++--- .../Exercises/Blanks/Letters/index.tsx | 23 +- .../Exercises/Blanks/MultipleChoice/index.tsx | 23 +- .../Exercises/Blanks/WriteBlankFill/index.tsx | 24 +- .../ExamEditor/Exercises/Blanks/index.tsx | 8 +- .../Exercises/MatchSentences/index.tsx | 25 +- .../MultipleChoice/Underline/index.tsx | 24 +- .../MultipleChoice/Vanilla/index.tsx | 24 +- .../ExamEditor/Exercises/Script/index.tsx | 1 - .../Speaking/InteractiveSpeaking.tsx | 85 ++-- .../Exercises/Speaking/Speaking1.tsx | 71 ++- .../Exercises/Speaking/Speaking2.tsx | 77 +-- .../ExamEditor/Exercises/Speaking/index.tsx | 7 - .../ExamEditor/Exercises/TrueFalse/index.tsx | 23 +- .../Exercises/WriteBlanks/index.tsx | 28 +- .../Exercises/WriteBlanksForm/index.tsx | 23 +- .../ExamEditor/Exercises/Writing/index.tsx | 71 ++- .../ExamEditor/Hooks/useSectionEdit.tsx | 31 +- .../ExamEditor/Hooks/useSettingsState.tsx | 4 +- .../ImportExam/ImportOrFromScratch.tsx | 2 +- .../{Shared => }/ImportExam/WordUploader.tsx | 0 .../SectionRenderer/SectionContext/index.tsx | 41 +- .../SectionRenderer/SectionContext/level.tsx | 33 ++ .../SectionContext/listening.tsx | 114 ++++- .../SectionContext/reading.tsx | 83 ++- .../SectionExercises/exercises.tsx | 92 ++++ .../SectionExercises/fillBlanks.tsx | 78 +++ .../SectionExercises/index.tsx | 209 +++++--- .../SectionExercises/level.tsx | 75 --- .../SectionExercises/listening.tsx | 127 ----- .../SectionExercises/reading.tsx | 117 ----- .../SectionRenderer/SectionExercises/types.ts | 4 - .../SectionExercises/writeBlanks.tsx | 58 +++ .../ExamEditor/SectionRenderer/index.tsx | 7 +- .../ExamEditor/SectionRenderer/types.ts | 2 +- .../SettingsEditor/Shared/Generate.ts | 46 +- .../SettingsEditor/Shared/GenerateBtn.tsx | 33 +- .../SettingsEditor/Shared/SectionPicker.tsx | 98 ++++ .../Shared/SettingsDropdown.tsx | 5 +- .../ExamEditor/SettingsEditor/level.tsx | 162 +++++- .../SettingsEditor/listening/components.tsx | 206 ++++++++ .../{listening.tsx => listening/index.tsx} | 167 +----- .../SettingsEditor/reading/components.tsx | 108 ++++ .../{reading.tsx => reading/index.tsx} | 84 +-- .../ExamEditor/SettingsEditor/speaking.tsx | 481 ------------------ .../SettingsEditor/speaking/components.tsx | 268 ++++++++++ .../SettingsEditor/speaking/index.tsx | 261 ++++++++++ .../SettingsEditor/writing/components.tsx | 85 ++++ .../{writing.tsx => writing/index.tsx} | 71 +-- .../ExamEditor/Shared/ExerciseLabel.tsx | 23 +- src/components/ExamEditor/Shared/Header.tsx | 41 +- src/components/ExamEditor/index.tsx | 2 +- src/exams/Listening.tsx | 5 +- src/exams/Reading.tsx | 3 +- src/interfaces/exam.ts | 3 + src/pages/api/exam/generate/[...module].ts | 5 + src/stores/examEditor/defaults.ts | 56 +- src/stores/examEditor/reducers/index.ts | 1 + .../examEditor/reducers/moduleReducer.ts | 1 + .../examEditor/reducers/sectionReducer.ts | 71 ++- src/stores/examEditor/reorder/global.ts | 8 +- src/stores/examEditor/sections.ts | 3 +- src/stores/examEditor/types.ts | 69 ++- 66 files changed, 2546 insertions(+), 1635 deletions(-) rename src/components/ExamEditor/{Shared => }/ExercisePicker/ExerciseWizard.tsx (96%) rename src/components/ExamEditor/{Shared => }/ExercisePicker/exercises.ts (96%) rename src/components/ExamEditor/{Shared => }/ExercisePicker/generatedExercises.ts (100%) rename src/components/ExamEditor/{Shared => }/ExercisePicker/index.tsx (51%) rename src/components/ExamEditor/{Shared => }/ImportExam/ImportOrFromScratch.tsx (97%) rename src/components/ExamEditor/{Shared => }/ImportExam/WordUploader.tsx (100%) create mode 100644 src/components/ExamEditor/SectionRenderer/SectionContext/level.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/exercises.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/fillBlanks.tsx delete mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/level.tsx delete mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/listening.tsx delete mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/reading.tsx create mode 100644 src/components/ExamEditor/SectionRenderer/SectionExercises/writeBlanks.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/listening/components.tsx rename src/components/ExamEditor/SettingsEditor/{listening.tsx => listening/index.tsx} (54%) create mode 100644 src/components/ExamEditor/SettingsEditor/reading/components.tsx rename src/components/ExamEditor/SettingsEditor/{reading.tsx => reading/index.tsx} (59%) delete mode 100644 src/components/ExamEditor/SettingsEditor/speaking.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/speaking/components.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/speaking/index.tsx create mode 100644 src/components/ExamEditor/SettingsEditor/writing/components.tsx rename src/components/ExamEditor/SettingsEditor/{writing.tsx => writing/index.tsx} (63%) diff --git a/src/components/ExamEditor/Shared/ExercisePicker/ExerciseWizard.tsx b/src/components/ExamEditor/ExercisePicker/ExerciseWizard.tsx similarity index 96% rename from src/components/ExamEditor/Shared/ExercisePicker/ExerciseWizard.tsx rename to src/components/ExamEditor/ExercisePicker/ExerciseWizard.tsx index c90e5e28..b82c3751 100644 --- a/src/components/ExamEditor/Shared/ExercisePicker/ExerciseWizard.tsx +++ b/src/components/ExamEditor/ExercisePicker/ExerciseWizard.tsx @@ -7,13 +7,16 @@ import { GiBrain } from 'react-icons/gi'; import { IoTextOutline } from 'react-icons/io5'; import { Switch } from '@headlessui/react'; import useExamEditorStore from '@/stores/examEditor'; +import { Module } from '@/interfaces'; interface Props { + module: Module; sectionId: number; exercises: ExerciseGen[]; extraArgs?: Record; onSubmit: (configurations: ExerciseConfig[]) => void; onDiscard: () => void; + selectedExercises: string[]; } export interface ExerciseConfig { @@ -24,15 +27,14 @@ export interface ExerciseConfig { } const ExerciseWizard: React.FC = ({ + module, exercises, extraArgs, sectionId, + selectedExercises, onSubmit, onDiscard, }) => { - const {currentModule} = useExamEditorStore(); - const { selectedExercises } = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId === sectionId))!; - const [configurations, setConfigurations] = useState([]); useEffect(() => { @@ -236,7 +238,7 @@ const ExerciseWizard: React.FC = ({ return (
{renderExerciseHeader(exercise, exerciseIndex, config, (exercise.extra || []).length > 2)} @@ -262,7 +264,7 @@ const ExerciseWizard: React.FC = ({ diff --git a/src/components/ExamEditor/Shared/ExercisePicker/exercises.ts b/src/components/ExamEditor/ExercisePicker/exercises.ts similarity index 96% rename from src/components/ExamEditor/Shared/ExercisePicker/exercises.ts rename to src/components/ExamEditor/ExercisePicker/exercises.ts index 295b0f2c..16216b9e 100644 --- a/src/components/ExamEditor/Shared/ExercisePicker/exercises.ts +++ b/src/components/ExamEditor/ExercisePicker/exercises.ts @@ -321,6 +321,12 @@ const EXERCISES: ExerciseGen[] = [ type: "writing_letter", icon: FaEnvelope, extra: [ + { + label: "Letter Topic", + param: "topic", + value: "", + type: "text" + }, generate() ], module: "writing" @@ -330,6 +336,12 @@ const EXERCISES: ExerciseGen[] = [ type: "writing_2", icon: FaFileAlt, extra: [ + { + label: "Essay Topic", + param: "topic", + value: "", + type: "text" + }, generate() ], module: "writing" diff --git a/src/components/ExamEditor/Shared/ExercisePicker/generatedExercises.ts b/src/components/ExamEditor/ExercisePicker/generatedExercises.ts similarity index 100% rename from src/components/ExamEditor/Shared/ExercisePicker/generatedExercises.ts rename to src/components/ExamEditor/ExercisePicker/generatedExercises.ts diff --git a/src/components/ExamEditor/Shared/ExercisePicker/index.tsx b/src/components/ExamEditor/ExercisePicker/index.tsx similarity index 51% rename from src/components/ExamEditor/Shared/ExercisePicker/index.tsx rename to src/components/ExamEditor/ExercisePicker/index.tsx index 26623dd7..2ff6bce8 100644 --- a/src/components/ExamEditor/Shared/ExercisePicker/index.tsx +++ b/src/components/ExamEditor/ExercisePicker/index.tsx @@ -2,35 +2,39 @@ import EXERCISES from "./exercises"; import clsx from "clsx"; import { ExerciseGen, GeneratedExercises, GeneratorState } from "./generatedExercises"; import Modal from "@/components/Modal"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard"; -import { generate } from "../../SettingsEditor/Shared/Generate"; +import { generate } from "../SettingsEditor/Shared/Generate"; import { Module } from "@/interfaces"; import useExamEditorStore from "@/stores/examEditor"; -import { ListeningPart, Message, ReadingPart } from "@/interfaces/exam"; +import { LevelPart, ListeningPart, Message, ReadingPart } from "@/interfaces/exam"; import { BsArrowRepeat } from "react-icons/bs"; +import { writingTask } from "@/stores/examEditor/sections"; interface ExercisePickerProps { module: string; sectionId: number; difficulty: string; extraArgs?: Record; + levelSectionId?: number; + level?: boolean; } const ExercisePicker: React.FC = ({ module, sectionId, extraArgs = undefined, + levelSectionId, + level = false }) => { const { currentModule, dispatch } = useExamEditorStore(); - const { difficulty } = useExamEditorStore((store) => store.modules[currentModule]); - const section = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId == sectionId)); + const { difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule]); + const section = sections.find((s) => s.sectionId === (level ? levelSectionId : sectionId)); const [pickerOpen, setPickerOpen] = useState(false); + const [localSelectedExercises, setLocalSelectedExercises] = useState([]); - if (section === undefined) return <>; - - const { state, selectedExercises } = section; + const state = section?.state; const getFullExerciseType = (exercise: ExerciseGen): string => { if (exercise.extra && exercise.extra.length > 0) { @@ -43,19 +47,19 @@ const ExercisePicker: React.FC = ({ const handleChange = (exercise: ExerciseGen) => { const fullType = getFullExerciseType(exercise); - const newSelected = selectedExercises.includes(fullType) - ? selectedExercises.filter(type => type !== fullType) - : [...selectedExercises, fullType]; - - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "selectedExercises", value: newSelected } }) + setLocalSelectedExercises(prev => { + const newSelected = prev.includes(fullType) + ? prev.filter(type => type !== fullType) + : [...prev, fullType]; + return newSelected; + }); }; - const moduleExercises = (sectionId && module !== "level" ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module)); + const moduleExercises = (sectionId && !["level", "writing", "speaking"].includes(module) ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module)); - const onModuleSpecific = (configurations: ExerciseConfig[]) => { + const onModuleSpecific = useCallback((configurations: ExerciseConfig[]) => { const exercises = configurations.map(config => { const exerciseType = config.type.split('name=')[1]; - return { type: exerciseType, quantity: Number(config.params.quantity || 1), @@ -68,56 +72,97 @@ const ExercisePicker: React.FC = ({ }; }); - let context, moduleState; - - switch (module) { - case 'reading': - moduleState = state as ReadingPart; + let context = {}; + if (module === 'reading') { + const readingState = state as ReadingPart | LevelPart; + context = { + text: readingState.text!.content + }; + } else if (module === 'listening') { + const listeningState = state as ListeningPart | LevelPart; + const script = listeningState.script; + if (sectionId === 1 || sectionId === 3) { + const dialog = script as Message[]; context = { - text: moduleState.text.content - } - break; - case 'listening': - moduleState = state as ListeningPart; - let script = moduleState.script; - let text, dialog; - switch (sectionId) { - case 1: - case 3: - dialog = script as Message[]; - text = dialog.map((d) => `${d.name}: ${d.text}`).join("\n"); - context = { text: text } - break; - case 2: - case 4: - text = script as string; - context = { text: text } - break; - } - break; - default: - context = {} + text: dialog.map((d) => `${d.name}: ${d.text}`).join("\n") + }; + } else if (sectionId === 2 || sectionId === 4) { + context = { + text: script as string + }; + } } - generate( - sectionId, - module as Module, - "exercises", - { - method: 'POST', - body: { - ...context, - exercises: exercises, - difficulty: difficulty + if (!["speaking", "writing"].includes(module)) { + generate( + sectionId, + module as Module, + level ? `exercises-${module}` : "exercises", + { + method: 'POST', + body: { + ...context, + exercises: exercises, + difficulty: difficulty + } + }, + (data: any) => [{ + exercises: data.exercises + }], + levelSectionId, + level + ); + } else if (module === "writing") { + configurations.forEach((config) => { + let queryParams = config.params.topic !== '' ? { topic: config.params.topic as string } : undefined; + generate( + config.type === 'writing_letter' ? 1 : 2, + "writing", + config.type, + { + method: 'GET', + queryParams + }, + (data: any) => [{ + prompt: data.question + }], + levelSectionId, + level + ); + }); + } else { + /*const newExercises = configurations.map((config) => { + switch (config.type) { + case 'writing_letter': + return { ...writingTask(1), level: true }; + case 'writing_2': + return { ...writingTask(2), level: true }; } - }, - (data: any) => [{ - exercises: data.exercises - }] - ); - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "selectedExercises", value: [] } }) + return undefined; + }).filter((ex) => ex !== undefined); + dispatch({ + type: "UPDATE_SECTION_STATE", payload: { + module: level ? "level" : module as Module, sectionId, update: { + exercises: [ + ...(sections.find((s) => s.sectionId = sectionId)?.state as LevelPart).exercises, + ...newExercises + ] + } + } + })*/ + } + setLocalSelectedExercises([]); setPickerOpen(false); - }; + }, [ + sectionId, + levelSectionId, + level, + module, + state, + difficulty, + setPickerOpen + ]); + if (section === undefined) return <>; return ( <> @@ -131,6 +176,8 @@ const ExercisePicker: React.FC = ({ )} > = ({ type="checkbox" name="exercise" value={fullType} - checked={selectedExercises.includes(fullType)} + checked={localSelectedExercises.includes(fullType)} onChange={() => handleChange(exercise)} className="h-5 w-5" /> @@ -171,14 +218,14 @@ const ExercisePicker: React.FC = ({ ) } onClick={() => setPickerOpen(true)} - disabled={selectedExercises.length == 0} + disabled={localSelectedExercises.length === 0} > {section.generating === "exercises" ? (
) : ( - <>Set Up Exercises ({selectedExercises.length}) + <>{["speaking", "writing"].includes(module) ? "Add Exercises" : "Set Up Exercises"} ({localSelectedExercises.length}) )}
@@ -187,4 +234,4 @@ const ExercisePicker: React.FC = ({ ); }; -export default ExercisePicker; +export default ExercisePicker; \ No newline at end of file diff --git a/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx index 4d11f4b1..fbc85ae0 100644 --- a/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx +++ b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx @@ -47,7 +47,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num setEditing, }); - const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({ sectionId, editing, setEditing, @@ -74,7 +74,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num ex.id === exercise.id ? updatedExercise : ex ); - dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); }, onDiscard: () => { setSelectedBlankId(null); @@ -96,12 +96,23 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); }, - onMode: () => { + onDelete: () => { const newSection = { ...section, exercises: section.exercises.filter((ex) => ex.id !== local.id) }; - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } }); + }, + onPractice: () => { + const updatedExercise = { + ...local, + //isPractice: !isPractice, + }; + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); } }); @@ -252,8 +263,10 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num onBlankRemove={handleBlankRemove} onSave={handleSave} onDiscard={handleDiscard} - onDelete={modeHandle} + onDelete={handleDelete} setEditing={setEditing} + onPractice={handlePractice} + isEvaluationEnabled={true}//local.isPractice} > <> {!blanksState.textMode && diff --git a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx index aa78c1e6..f5343b70 100644 --- a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx +++ b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx @@ -45,7 +45,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } setEditing, }); - const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({ sectionId, editing, setEditing, @@ -72,7 +72,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } ex.id === exercise.id ? updatedExercise : ex ); - dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); }, onDiscard: () => { setSelectedBlankId(null); @@ -92,12 +92,23 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } }, [] as BlankState[]); blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks }); }, - onMode: () => { + onDelete: () => { const newSection = { ...section, exercises: section.exercises.filter((ex) => ex.id !== local.id) }; - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } }); + }, + onPractice: () => { + const updatedExercise = { + ...local, + //isPractice: !isPractice, + }; + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); } }); @@ -251,9 +262,11 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)} onSave={handleSave} onDiscard={handleDiscard} - onDelete={modeHandle} + onDelete={handleDelete} + onPractice={handlePractice} setEditing={setEditing} onBlankRemove={handleBlankRemove} + isEvaluationEnabled={true} > {!blanksState.textMode && selectedBlankId && ( diff --git a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx index 99cae042..bbbeea41 100644 --- a/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx +++ b/src/components/ExamEditor/Exercises/Blanks/WriteBlankFill/index.tsx @@ -10,7 +10,6 @@ import setEditingAlert from "../../Shared/setEditingAlert"; import { blanksReducer } from "../BlanksReducer"; import { validateWriteBlanks } from "./validation"; import AlternativeSolutions from "./AlternativeSolutions"; -import clsx from "clsx"; const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { const { currentModule, dispatch } = useExamEditorStore(); @@ -34,7 +33,7 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb setEditing, }); - const { handleSave, handleDiscard, modeHandle } = useSectionEdit({ + const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({ sectionId, editing, setEditing, @@ -57,19 +56,30 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb ex.id === exercise.id ? updatedExercise : ex ); - dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); }, onDiscard: () => { setSelectedBlankId(null); setLocal(exercise); blanksDispatcher({ type: "RESET", payload: { text: exercise.text } }); }, - onMode: () => { + onDelete: () => { const newSection = { ...section, exercises: section.exercises.filter((ex) => ex.id !== local.id) }; - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } }); + }, + onPractice: () => { + const updatedExercise = { + ...local, + isPractice: true, + }; + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); } }); @@ -160,8 +170,10 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb onBlankRemove={handleBlankRemove} onSave={handleSave} onDiscard={handleDiscard} - onDelete={modeHandle} + onDelete={handleDelete} + onPractice={handlePractice} setEditing={setEditing} + isEvaluationEnabled={true} > {!blanksState.textMode && ( diff --git a/src/components/ExamEditor/Exercises/Blanks/index.tsx b/src/components/ExamEditor/Exercises/Blanks/index.tsx index 855a4c7d..3fc349ca 100644 --- a/src/components/ExamEditor/Exercises/Blanks/index.tsx +++ b/src/components/ExamEditor/Exercises/Blanks/index.tsx @@ -37,6 +37,8 @@ interface Props { onSave: () => void; onDiscard: () => void; onDelete: () => void; + onPractice: () => void; + isEvaluationEnabled: boolean; children: ReactNode; } @@ -56,6 +58,8 @@ const BlanksEditor: React.FC = ({ onSave, onDiscard, onDelete, + onPractice, + isEvaluationEnabled, setEditing }) => { @@ -161,8 +165,10 @@ const BlanksEditor: React.FC = ({ description={description} editing={editing} handleSave={onSave} - modeHandle={onDelete} + handleDelete={onDelete} handleDiscard={onDiscard} + handlePractice={onPractice} + isEvaluationEnabled={isEvaluationEnabled} /> {alerts.length > 0 && } diff --git a/src/components/ExamEditor/Exercises/MatchSentences/index.tsx b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx index 18dbf2e3..b8b372e2 100644 --- a/src/components/ExamEditor/Exercises/MatchSentences/index.tsx +++ b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx @@ -37,7 +37,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu setEditing(true); }; - const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({ + const { editing, setEditing, handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({ sectionId, onSave: () => { @@ -53,19 +53,30 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu const newState = { ...section }; newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex); - dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } }); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); }, onDiscard: () => { setLocal(exercise); setSelectedParagraph(null); setShowReference(false); }, - onMode: () => { + onDelete: () => { const newSection = { ...section, exercises: section.exercises.filter((ex) => ex.id !== local.id) }; - dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } }); + dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } }); + }, + onPractice: () => { + const updatedExercise = { + ...local, + //isPractice: !isPractice, + }; + const newState = { ...section }; + newState.exercises = newState.exercises.map((ex) => + ex.id === exercise.id ? updatedExercise : ex + ); + dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } }); } }); @@ -142,8 +153,10 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu description={`Edit ${exercise.variant && exercise.variant == "ideaMatch" ? "ideas/opinions" : "headings"} and their matches`} editing={editing} handleSave={handleSave} - modeHandle={modeHandle} + handleDelete={handleDelete} handleDiscard={handleDiscard} + handlePractice={handlePractice} + isEvaluationEnabled={true} > ); -} +} export default GenerateBtn; diff --git a/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx b/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx new file mode 100644 index 00000000..b27198f4 --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/Shared/SectionPicker.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Module } from "@/interfaces"; +import useExamEditorStore from "@/stores/examEditor"; +import Dropdown from "./SettingsDropdown"; +import { LevelSectionSettings } from "@/stores/examEditor/types"; + +interface Props { + module: Module; + sectionId: number; + localSettings: LevelSectionSettings; + updateLocalAndScheduleGlobal: (updates: Partial, schedule?: boolean) => void; +} + +const SectionPicker: React.FC = ({ + module, + sectionId, + localSettings, + updateLocalAndScheduleGlobal +}) => { + const { dispatch } = useExamEditorStore(); + const [selectedValue, setSelectedValue] = React.useState(null); + + const sectionState = useExamEditorStore(state => + state.modules["level"].sections.find((s) => s.sectionId === sectionId) + ); + + if (sectionState === undefined) return null; + + const { readingSection, listeningSection } = sectionState; + const currentValue = selectedValue ?? (module === "reading" ? readingSection : listeningSection); + const options = module === "reading" ? [1, 2, 3] : [1, 2, 3, 4]; + const openPicker = module === "reading" ? "isReadingPickerOpen" : "isListeningPickerOpen"; + + const handleSectionChange = (value: number) => { + setSelectedValue(value); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", + payload: { + sectionId, + module: "level", + field: module === "reading" ? "readingSection" : "listeningSection", + value: value + } + }); + }; + + const getTitle = () => { + const section = module === "reading" ? "Passage" : "Section"; + if (!currentValue) return `Choose a ${section}`; + return `${section} ${currentValue}`; + }; + + return ( + + updateLocalAndScheduleGlobal({ [openPicker]: isOpen }, false) + } + contentWrapperClassName={`pt-6 px-4 bg-gray-200 rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-${module}`} + > +
+ {options.map((num) => ( + + ))} +
+
+ ); +}; + +export default SectionPicker; \ No newline at end of file diff --git a/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx b/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx index 4f0c0949..ea27a551 100644 --- a/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx +++ b/src/components/ExamEditor/SettingsEditor/Shared/SettingsDropdown.tsx @@ -10,9 +10,10 @@ interface Props { setIsOpen: (isOpen: boolean) => void; children: ReactNode; center?: boolean; + contentWrapperClassName?: string; } -const SettingsDropdown: React.FC = ({ module, title, open, setIsOpen, children, disabled = false, center = false}) => { +const SettingsDropdown: React.FC = ({ module, title, open, setIsOpen, children, contentWrapperClassName = '', 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 ${center ? "flex justify-center" : ""}`} + contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${center ? "flex justify-center" : ""} ${contentWrapperClassName}`} open={open} setIsOpen={setIsOpen} disabled={disabled} diff --git a/src/components/ExamEditor/SettingsEditor/level.tsx b/src/components/ExamEditor/SettingsEditor/level.tsx index ccbfa485..2069a08a 100644 --- a/src/components/ExamEditor/SettingsEditor/level.tsx +++ b/src/components/ExamEditor/SettingsEditor/level.tsx @@ -1,18 +1,24 @@ -import { Exercise, LevelExam, LevelPart } from "@/interfaces/exam"; +import { Exercise, InteractiveSpeakingExercise, LevelExam, LevelPart, SpeakingExercise } from "@/interfaces/exam"; import SettingsEditor from "."; import Option from "@/interfaces/option"; import Dropdown from "@/components/Dropdown"; import clsx from "clsx"; -import ExercisePicker from "../Shared/ExercisePicker"; +import ExercisePicker from "../ExercisePicker"; import useExamEditorStore from "@/stores/examEditor"; import useSettingsState from "../Hooks/useSettingsState"; -import { SectionSettings } from "@/stores/examEditor/types"; +import { LevelSectionSettings, SectionSettings } from "@/stores/examEditor/types"; import { toast } from "react-toastify"; import axios from "axios"; import { playSound } from "@/utils/sound"; import { useRouter } from "next/router"; import { usePersistentExamStore } from "@/stores/examStore"; import openDetachedTab from "@/utils/popout"; +import ListeningComponents from "./listening/components"; +import ReadingComponents from "./reading/components"; +import WritingComponents from "./writing/components"; +import SpeakingComponents from "./speaking/components"; +import SectionPicker from "./Shared/SectionPicker"; +import SettingsDropdown from "./Shared/SettingsDropdown"; const LevelSettings: React.FC = () => { @@ -26,8 +32,8 @@ const LevelSettings: React.FC = () => { setQuestionIndex, setBgColor, } = usePersistentExamStore(); - - const {currentModule, title } = useExamEditorStore(); + + const { currentModule, title } = useExamEditorStore(); const { focusedSection, difficulty, @@ -36,15 +42,18 @@ const LevelSettings: React.FC = () => { isPrivate, } = useExamEditorStore(state => state.modules[currentModule]); - const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( + const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( currentModule, focusedSection ); const section = sections.find((section) => section.sectionId == focusedSection); + const focusedExercise = section?.focusedExercise; if (section === undefined) return <>; const currentSection = section.state as LevelPart; + const readingSection = section.readingSection; + const listeningSection = section.listeningSection; const canPreview = currentSection.exercises.length > 0; @@ -105,6 +114,8 @@ const LevelSettings: React.FC = () => { openDetachedTab("popout?type=Exam&module=level", router) } + const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises[focusedExercise] as SpeakingExercise | InteractiveSpeakingExercise; + return ( { submitModule={submitLevel} >
- updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)} + open={localSettings.isLevelDropdownOpen} + setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isLevelDropdownOpen: isOpen }, false)} > + module="level" + sectionId={focusedSection} + difficulty={difficulty} + />
-
+
+ updateLocalAndScheduleGlobal({ isReadingDropdownOpen: isOpen }, false)} + > +
+ + +
+
+
+
+ updateLocalAndScheduleGlobal({ isListeningDropdownOpen: isOpen }, false)} + > +
+ + +
+
+
+
+ updateLocalAndScheduleGlobal({ isWritingDropdownOpen: isOpen }, false)} + > + + +
+
+ updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} + > + updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} + > +
+ +
+
+ + {speakingExercise !== undefined && + updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)} + > + + + } +
+
+ ); }; diff --git a/src/components/ExamEditor/SettingsEditor/listening/components.tsx b/src/components/ExamEditor/SettingsEditor/listening/components.tsx new file mode 100644 index 00000000..fafa2ffb --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/listening/components.tsx @@ -0,0 +1,206 @@ +import Dropdown from "../Shared/SettingsDropdown"; +import ExercisePicker from "../../ExercisePicker"; +import GenerateBtn from "../Shared/GenerateBtn"; +import { useCallback } from "react"; +import { generate } from "../Shared/Generate"; +import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types"; +import useExamEditorStore from "@/stores/examEditor"; +import { LevelPart, ListeningPart } from "@/interfaces/exam"; +import Input from "@/components/Low/Input"; +import axios from "axios"; +import { toast } from "react-toastify"; +import { playSound } from "@/utils/sound"; + +interface Props { + localSettings: ListeningSectionSettings | LevelSectionSettings; + updateLocalAndScheduleGlobal: (updates: Partial, schedule?: boolean) => void; + currentSection: ListeningPart | LevelPart; + audioContextDisabled?: boolean; + levelId?: number; + level?: boolean; +} + +const ListeningComponents: React.FC = ({ currentSection, localSettings, updateLocalAndScheduleGlobal, levelId, level = false, audioContextDisabled = false }) => { + const { currentModule, dispatch, modules } = useExamEditorStore(); + const { + focusedSection, + difficulty, + } = useExamEditorStore(state => state.modules[currentModule]); + + const generateScript = useCallback(() => { + generate( + levelId ? levelId : focusedSection, + "listening", + "listeningScript", + { + method: 'GET', + queryParams: { + difficulty, + ...(localSettings.listeningTopic && { topic: localSettings.listeningTopic }) + } + }, + (data: any) => [{ + script: data.dialog + }], + level ? focusedSection : undefined, + level + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localSettings.listeningTopic, difficulty, focusedSection, levelId, level]); + + const onTopicChange = useCallback((listeningTopic: string) => { + updateLocalAndScheduleGlobal({ listeningTopic }); + }, [updateLocalAndScheduleGlobal]); + + + const generateAudio = useCallback(async (sectionId: number) => { + let body: any; + if ([1, 3].includes(levelId ? levelId : focusedSection)) { + body = { conversation: currentSection.script } + } else { + body = { monologue: currentSection.script } + } + + try { + if (level) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, module: "level", field: "levelGenerating", value: + [...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating, "audio"] + } + }); + } else { + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "audio" } }); + } + + 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, + module: level ? "level" : "listening", + update: { + audio: { + source: url, + repeatableTimes: 3 + } + } + } + }); + playSound("check"); + toast.success('Audio generated successfully!'); + } catch (error: any) { + toast.error('Failed to generate audio'); + } finally { + if (level) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, module: "level", field: "levelGenerating", value: + [...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating.filter(g => g !== "audio")] + } + }); + } else { + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } }); + } + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentSection?.script, dispatch, level, levelId]); + + return ( + <> + updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-listening` : ''} + > +
+
+ + +
+
+ +
+
+
+ updateLocalAndScheduleGlobal({ isListeningTopicOpen: isOpen }, false)} + disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined} + contentWrapperClassName={level ? `border border-ielts-listening` : ''} + > + + + + updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)} + disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0} + contentWrapperClassName={level ? `border border-ielts-listening` : ''} + > +
+ + Generate audio recording for this section + +
+ +
+
+
+ + ); +}; + +export default ListeningComponents; \ No newline at end of file diff --git a/src/components/ExamEditor/SettingsEditor/listening.tsx b/src/components/ExamEditor/SettingsEditor/listening/index.tsx similarity index 54% rename from src/components/ExamEditor/SettingsEditor/listening.tsx rename to src/components/ExamEditor/SettingsEditor/listening/index.tsx index 5de632d3..c202c7f7 100644 --- a/src/components/ExamEditor/SettingsEditor/listening.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/index.tsx @@ -1,13 +1,13 @@ -import Dropdown from "./Shared/SettingsDropdown"; -import ExercisePicker from "../Shared/ExercisePicker"; -import SettingsEditor from "."; -import GenerateBtn from "./Shared/GenerateBtn"; +import Dropdown from "../Shared/SettingsDropdown"; +import ExercisePicker from "../../ExercisePicker"; +import SettingsEditor from ".."; +import GenerateBtn from "../Shared/GenerateBtn"; import { useCallback, useState } from "react"; -import { generate } from "./Shared/Generate"; -import { Generating, ListeningSectionSettings } from "@/stores/examEditor/types"; +import { generate } from "../Shared/Generate"; +import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types"; import Option from "@/interfaces/option"; import useExamEditorStore from "@/stores/examEditor"; -import useSettingsState from "../Hooks/useSettingsState"; +import useSettingsState from "../../Hooks/useSettingsState"; import { ListeningExam, ListeningPart } from "@/interfaces/exam"; import Input from "@/components/Low/Input"; import openDetachedTab from "@/utils/popout"; @@ -16,10 +16,11 @@ import axios from "axios"; import { usePersistentExamStore } from "@/stores/examStore"; import { playSound } from "@/utils/sound"; import { toast } from "react-toastify"; +import ListeningComponents from "./components"; const ListeningSettings: React.FC = () => { const router = useRouter(); - const { currentModule, title, dispatch } = useExamEditorStore(); + const { currentModule, title } = useExamEditorStore(); const { focusedSection, difficulty, @@ -47,45 +48,21 @@ const ListeningSettings: React.FC = () => { { label: "Preset: Listening Section 1", value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions." - }, - { + }, + { label: "Preset: Listening Section 2", value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements." - }, - { + }, + { label: "Preset: Listening Section 3", value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements." - }, - { + }, + { label: "Preset: Listening Section 4", value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject." - } + } ]; - - const generateScript = useCallback(() => { - generate( - focusedSection, - currentModule, - "context", - { - method: 'GET', - queryParams: { - difficulty, - ...(localSettings.topic && { topic: localSettings.topic }) - } - }, - (data: any) => [{ - script: data.dialog - }] - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings.topic, difficulty, focusedSection]); - - const onTopicChange = useCallback((topic: string) => { - updateLocalAndScheduleGlobal({ topic }); - }, [updateLocalAndScheduleGlobal]); - const submitListening = async () => { if (title === "") { toast.error("Enter a title for the exam!"); @@ -187,56 +164,6 @@ 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 { - dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: "media"}}); - 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 { - dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: undefined}}); - } - - // 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 @@ -259,65 +186,9 @@ const ListeningSettings: React.FC = () => { preview={preview} submitModule={submitListening} > - updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)} - > -
-
- - -
-
- -
-
-
- updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)} - disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined} - > - - - - updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)} - disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0} - center - > - - + ); }; diff --git a/src/components/ExamEditor/SettingsEditor/reading/components.tsx b/src/components/ExamEditor/SettingsEditor/reading/components.tsx new file mode 100644 index 00000000..2271efa1 --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/reading/components.tsx @@ -0,0 +1,108 @@ +import React, { useCallback } from "react"; +import Dropdown from "../Shared/SettingsDropdown"; +import Input from "@/components/Low/Input"; +import ExercisePicker from "../../ExercisePicker"; +import { generate } from "../Shared/Generate"; +import GenerateBtn from "../Shared/GenerateBtn"; +import { LevelPart, ReadingPart } from "@/interfaces/exam"; +import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types"; +import useExamEditorStore from "@/stores/examEditor"; + +interface Props { + localSettings: ReadingSectionSettings | LevelSectionSettings; + updateLocalAndScheduleGlobal: (updates: Partial, schedule?: boolean) => void; + currentSection: ReadingPart | LevelPart; + generatePassageDisabled?: boolean; + levelId?: number; + level?: boolean; +} + +const ReadingComponents: React.FC = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => { + const { currentModule } = useExamEditorStore(); + const { + focusedSection, + difficulty, + } = useExamEditorStore(state => state.modules[currentModule]); + + const generatePassage = useCallback(() => { + generate( + levelId ? levelId : focusedSection, + "reading", + "passage", + { + method: 'GET', + queryParams: { + difficulty, + ...(localSettings.readingTopic && { topic: localSettings.readingTopic }) + } + }, + (data: any) => [{ + title: data.title, + text: data.text + }], + level ? focusedSection : undefined, + level + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localSettings.readingTopic, difficulty, focusedSection, levelId]); + + const onTopicChange = useCallback((readingTopic: string) => { + updateLocalAndScheduleGlobal({ readingTopic }); + }, [updateLocalAndScheduleGlobal]); + + return ( + <> + updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-reading`: ''} + disabled={generatePassageDisabled} + > +
+
+ + +
+
+ +
+
+
+ updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })} + contentWrapperClassName={level ? `border border-ielts-reading`: ''} + disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""} + > + + + + ); +}; + +export default ReadingComponents; diff --git a/src/components/ExamEditor/SettingsEditor/reading.tsx b/src/components/ExamEditor/SettingsEditor/reading/index.tsx similarity index 59% rename from src/components/ExamEditor/SettingsEditor/reading.tsx rename to src/components/ExamEditor/SettingsEditor/reading/index.tsx index d4b9c800..65d6b75e 100644 --- a/src/components/ExamEditor/SettingsEditor/reading.tsx +++ b/src/components/ExamEditor/SettingsEditor/reading/index.tsx @@ -1,12 +1,7 @@ -import React, { useCallback, useState } from "react"; -import SettingsEditor from "."; +import React from "react"; +import SettingsEditor from ".."; import Option from "@/interfaces/option"; -import Dropdown from "./Shared/SettingsDropdown"; -import Input from "@/components/Low/Input"; -import ExercisePicker from "../Shared/ExercisePicker"; -import { generate } from "./Shared/Generate"; -import GenerateBtn from "./Shared/GenerateBtn"; -import useSettingsState from "../Hooks/useSettingsState"; +import useSettingsState from "../../Hooks/useSettingsState"; import { ReadingExam, ReadingPart } from "@/interfaces/exam"; import { ReadingSectionSettings } from "@/stores/examEditor/types"; import useExamEditorStore from "@/stores/examEditor"; @@ -16,6 +11,7 @@ import { usePersistentExamStore } from "@/stores/examStore"; import axios from "axios"; import { playSound } from "@/utils/sound"; import { toast } from "react-toastify"; +import ReadingComponents from "./components"; const ReadingSettings: React.FC = () => { const router = useRouter(); @@ -60,32 +56,6 @@ const ReadingSettings: React.FC = () => { } ]; - - const generatePassage = useCallback(() => { - generate( - focusedSection, - currentModule, - "context", - { - method: 'GET', - queryParams: { - difficulty, - ...(localSettings.topic && { topic: localSettings.topic }) - } - }, - (data: any) => [{ - title: data.title, - text: data.text - }] - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings.topic, difficulty, focusedSection]); - - const onTopicChange = useCallback((topic: string) => { - updateLocalAndScheduleGlobal({ topic }); - }, [updateLocalAndScheduleGlobal]); - - const canPreviewOrSubmit = sections.some( (s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0 ); @@ -161,49 +131,9 @@ const ReadingSettings: React.FC = () => { canSubmit={canPreviewOrSubmit} submitModule={submitReading} > - updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)} - > -
-
- - -
-
- -
-
-
- updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })} - disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""} - > - - + ); }; diff --git a/src/components/ExamEditor/SettingsEditor/speaking.tsx b/src/components/ExamEditor/SettingsEditor/speaking.tsx deleted file mode 100644 index af4defbf..00000000 --- a/src/components/ExamEditor/SettingsEditor/speaking.tsx +++ /dev/null @@ -1,481 +0,0 @@ -import useExamEditorStore from "@/stores/examEditor"; -import useSettingsState from "../Hooks/useSettingsState"; -import { SpeakingSectionSettings } from "@/stores/examEditor/types"; -import Option from "@/interfaces/option"; -import { useCallback, useState } from "react"; -import { generate } from "./Shared/Generate"; -import SettingsEditor from "."; -import Dropdown from "./Shared/SettingsDropdown"; -import Input from "@/components/Low/Input"; -import GenerateBtn from "./Shared/GenerateBtn"; -import clsx from "clsx"; -import { FaFemale, FaMale, FaChevronDown } from "react-icons/fa"; -import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam"; -import { toast } from "react-toastify"; -import { generateVideos } from "./Shared/generateVideos"; -import { usePersistentExamStore } from "@/stores/examStore"; -import { useRouter } from "next/router"; -import openDetachedTab from "@/utils/popout"; -import axios from "axios"; -import { playSound } from "@/utils/sound"; - -export interface Avatar { - name: string; - gender: string; -} - -const SpeakingSettings: React.FC = () => { - - const router = useRouter(); - - const { - setExam, - setExerciseIndex, - setQuestionIndex, - setBgColor, - } = usePersistentExamStore(); - - const { title, currentModule, speakingAvatars, dispatch } = useExamEditorStore(); - const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule]) - - const section = sections.find((section) => section.sectionId == focusedSection)?.state; - - - const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( - currentModule, - focusedSection, - ); - - const [selectedAvatar, setSelectedAvatar] = useState(null); - - const defaultPresets: Option[] = [ - { - label: "Preset: Speaking Part 1", - value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked." - }, - { - label: "Preset: Speaking Part 2", - value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience." - }, - { - label: "Preset: Speaking Part 3", - value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic." - } - ]; - - const generateScript = useCallback((sectionId: number) => { - const queryParams: { - difficulty: string; - first_topic?: string; - second_topic?: string; - topic?: string; - } = { difficulty }; - - if (sectionId === 1) { - if (localSettings.topic) { - queryParams['first_topic'] = localSettings.topic; - } - if (localSettings.secondTopic) { - queryParams['second_topic'] = localSettings.secondTopic; - } - } else { - if (localSettings.topic) { - queryParams['topic'] = localSettings.topic; - } - } - - generate( - sectionId, - currentModule, - "context", // <- not really context but exercises is reserved for reading, listening and level - { - method: 'GET', - queryParams - }, - (data: any) => { - switch (sectionId) { - case 1: - return [{ - prompts: data.questions, - first_topic: data.first_topic, - second_topic: data.second_topic - }]; - case 2: - return [{ - topic: data.topic, - question: data.question, - prompts: data.prompts, - suffix: data.suffix - }]; - case 3: - return [{ - title: data.topic, - prompts: data.questions - }]; - default: - return [data]; - } - } - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings, difficulty]); - - const onTopicChange = useCallback((topic: string) => { - updateLocalAndScheduleGlobal({ topic }); - }, [updateLocalAndScheduleGlobal]); - - const onSecondTopicChange = useCallback((topic: string) => { - updateLocalAndScheduleGlobal({ secondTopic: topic }); - }, [updateLocalAndScheduleGlobal]); - - const canPreviewOrSubmit = (() => { - return sections.every((s) => { - const section = s.state as SpeakingExercise | InteractiveSpeakingExercise; - switch (section.type) { - case 'speaking': - return section.title !== '' && - section.text !== '' && - section.video_url !== '' && - section.prompts.every(prompt => prompt !== ''); - - case 'interactiveSpeaking': - if ('first_title' in section && 'second_title' in section) { - return section.first_title !== '' && - section.second_title !== '' && - section.prompts.every(prompt => prompt.video_url !== '') && - section.prompts.length > 2; - } - return section.title !== '' && - section.prompts.every(prompt => prompt.video_url !== ''); - - default: - return false; - } - }); - })(); - - const canGenerate = section && (() => { - switch (focusedSection) { - case 1: { - const currentSection = section as InteractiveSpeakingExercise; - return currentSection.first_title !== "" && - currentSection.second_title !== "" && - currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2; - } - case 2: { - const currentSection = section as SpeakingExercise; - return currentSection.title !== "" && - currentSection.text !== "" && - currentSection.prompts.every(prompt => prompt !== ""); - } - case 3: { - const currentSection = section as InteractiveSpeakingExercise; - return currentSection.title !== "" && - currentSection.prompts.every(prompt => prompt.text !== ""); - } - default: - return false; - } - })(); - - const generateVideoCallback = useCallback((sectionId: number) => { - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "media" } }) - generateVideos( - section as InteractiveSpeakingExercise | SpeakingExercise, - sectionId, - selectedAvatar, - speakingAvatars - ).then((results) => { - switch (sectionId) { - case 1: - case 3: { - const interactiveSection = section as InteractiveSpeakingExercise; - const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({ - ...prompt, - video_url: results[index].url || '' - })); - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ prompts: updatedPrompts }] } }) - break; - } - case 2: { - if (results[0]?.url) { - dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ video_url: results[0].url }] } }) - } - break; - } - } - }).catch((error) => { - toast.error("Failed to generate the video, try again later!") - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAvatar, section]); - - - const submitSpeaking = async () => { - if (title === "") { - toast.error("Enter a title for the exam!"); - return; - } - - try { - const formData = new FormData(); - const urlMap = new Map(); - - const sectionsWithVideos = sections.filter(s => { - const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; - if (exercise.type === "speaking") { - return exercise.video_url !== ""; - } - if (exercise.type === "interactiveSpeaking") { - return exercise.prompts?.some(prompt => prompt.video_url !== ""); - } - return false; - }); - - if (sectionsWithVideos.length === 0) { - toast.error('No video sections found in the exam! Please record or import videos.'); - return; - } - - await Promise.all( - sectionsWithVideos.map(async (section) => { - const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise; - - if (exercise.type === "speaking") { - const response = await fetch(exercise.video_url); - const blob = await response.blob(); - formData.append('file', blob, 'video.mp4'); - urlMap.set(`${section.sectionId}`, exercise.video_url); - } else { - await Promise.all( - exercise.prompts.map(async (prompt, promptIndex) => { - if (prompt.video_url) { - const response = await fetch(prompt.video_url); - const blob = await response.blob(); - formData.append('file', blob, 'video.mp4'); - urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url); - } - }) - ); - } - }) - ); - - const response = await axios.post('/api/storage', formData, { - params: { - directory: 'speaking_videos' - }, - headers: { - 'Content-Type': 'multipart/form-data' - } - }); - - const { urls } = response.data; - - const exam: SpeakingExam = { - exercises: sections.map((s) => { - const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; - - if (exercise.type === "speaking") { - const videoIndex = Array.from(urlMap.entries()) - .findIndex(([key]) => key === `${s.sectionId}`); - - return { - ...exercise, - video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url, - intro: s.settings.currentIntro, - category: s.settings.category - }; - } else { - const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => { - const videoIndex = Array.from(urlMap.entries()) - .findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`); - - return { - ...prompt, - video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url - }; - }); - - return { - ...exercise, - prompts: updatedPrompts, - intro: s.settings.currentIntro, - category: s.settings.category - }; - } - }), - minTimer, - module: "speaking", - id: title, - isDiagnostic: false, - variant: undefined, - difficulty, - instructorGender: "varied", - private: isPrivate, - }; - - const result = await axios.post('/api/exam/speaking', exam); - playSound("sent"); - toast.success(`Submitted Exam ID: ${result.data.id}`); - - Array.from(urlMap.values()).forEach(url => { - URL.revokeObjectURL(url); - }); - - } catch (error: any) { - toast.error( - "Something went wrong while submitting, please try again later." - ); - } - }; - - const preview = () => { - setExam({ - exercises: sections - .filter((s) => { - const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; - - if (exercise.type === "speaking") { - return exercise.video_url !== ""; - } - - if (exercise.type === "interactiveSpeaking") { - return exercise.prompts?.every(prompt => prompt.video_url !== ""); - } - - return false; - }) - .map((s) => { - const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; - return { - ...exercise, - intro: s.settings.currentIntro, - category: s.settings.category - }; - }), - minTimer, - module: "speaking", - id: title, - isDiagnostic: false, - variant: undefined, - difficulty, - private: isPrivate, - } as SpeakingExam); - setExerciseIndex(0); - setQuestionIndex(0); - setBgColor("bg-white"); - openDetachedTab("popout?type=Exam&module=speaking", router) - } - - return ( - - updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)} - > - -
-
- - -
- {focusedSection === 1 && -
- - -
- } -
- -
-
-
- updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)} - > -
-
- -
- {selectedAvatar && ( - selectedAvatar.gender === 'male' ? ( - - ) : ( - - ) - )} -
-
- - -
-
-
- ); -}; - -export default SpeakingSettings; diff --git a/src/components/ExamEditor/SettingsEditor/speaking/components.tsx b/src/components/ExamEditor/SettingsEditor/speaking/components.tsx new file mode 100644 index 00000000..604cc4f0 --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/speaking/components.tsx @@ -0,0 +1,268 @@ +import useExamEditorStore from "@/stores/examEditor"; +import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types"; +import { useCallback, useState } from "react"; +import { generate } from "../Shared/Generate"; +import Dropdown from "../Shared/SettingsDropdown"; +import Input from "@/components/Low/Input"; +import GenerateBtn from "../Shared/GenerateBtn"; +import clsx from "clsx"; +import { FaFemale, FaMale } from "react-icons/fa"; +import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam"; +import { toast } from "react-toastify"; +import { generateVideos } from "../Shared/generateVideos"; +import { Module } from "@/interfaces"; + +export interface Avatar { + name: string; + gender: string; +} + +interface Props { + localSettings: SpeakingSectionSettings | LevelSectionSettings; + updateLocalAndScheduleGlobal: (updates: Partial, schedule?: boolean) => void; + section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart; + level?: boolean; + module?: Module; +} + +const SpeakingComponents: React.FC = ({ localSettings, updateLocalAndScheduleGlobal, section, level, module = "speaking" }) => { + + const { currentModule, speakingAvatars, dispatch } = useExamEditorStore(); + const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule]) + + const [selectedAvatar, setSelectedAvatar] = useState(null); + + const generateScript = useCallback((sectionId: number) => { + const queryParams: { + difficulty: string; + first_topic?: string; + second_topic?: string; + topic?: string; + } = { difficulty }; + + if (sectionId === 1) { + if (localSettings.speakingTopic) { + queryParams['first_topic'] = localSettings.speakingTopic; + } + if (localSettings.speakingSecondTopic) { + queryParams['second_topic'] = localSettings.speakingSecondTopic; + } + } else { + if (localSettings.speakingTopic) { + queryParams['topic'] = localSettings.speakingTopic; + } + } + + generate( + sectionId, + currentModule, + "speakingScript", + { + method: 'GET', + queryParams + }, + (data: any) => { + switch (sectionId) { + case 1: + return [{ + prompts: data.questions, + first_topic: data.first_topic, + second_topic: data.second_topic + }]; + case 2: + return [{ + topic: data.topic, + question: data.question, + prompts: data.prompts, + suffix: data.suffix + }]; + case 3: + return [{ + title: data.topic, + prompts: data.questions + }]; + default: + return [data]; + } + } + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localSettings, difficulty]); + + const onTopicChange = useCallback((speakingTopic: string) => { + updateLocalAndScheduleGlobal({ speakingTopic }); + }, [updateLocalAndScheduleGlobal]); + + const onSecondTopicChange = useCallback((speakingSecondTopic: string) => { + updateLocalAndScheduleGlobal({ speakingSecondTopic }); + }, [updateLocalAndScheduleGlobal]); + + const canGenerate = section && (() => { + switch (focusedSection) { + case 1: { + const currentSection = section as InteractiveSpeakingExercise; + return currentSection.first_title !== "" && + currentSection.second_title !== "" && + currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2; + } + case 2: { + const currentSection = section as SpeakingExercise; + return currentSection.title !== "" && + currentSection.text !== "" && + currentSection.prompts.every(prompt => prompt !== ""); + } + case 3: { + const currentSection = section as InteractiveSpeakingExercise; + return currentSection.title !== "" && + currentSection.prompts.every(prompt => prompt.text !== ""); + } + default: + return false; + } + })(); + + const generateVideoCallback = useCallback((sectionId: number) => { + dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "video" } }) + generateVideos( + section as InteractiveSpeakingExercise | SpeakingExercise, + sectionId, + selectedAvatar, + speakingAvatars + ).then((results) => { + switch (sectionId) { + case 1: + case 3: { + const interactiveSection = section as InteractiveSpeakingExercise; + const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({ + ...prompt, + video_url: results[index].url || '' + })); + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, module: currentModule, field: "genResult", value: + { generating: "video", result: [{ prompts: updatedPrompts }], module: module } + } + }) + break; + } + case 2: { + if (results[0]?.url) { + dispatch({ + type: "UPDATE_SECTION_SINGLE_FIELD", payload: { + sectionId, module: currentModule, field: "genResult", value: + { generating: "video", result: [{ video_url: results[0].url }], module: module } + } + }) + } + break; + } + } + }).catch((error) => { + toast.error("Failed to generate the video, try again later!") + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAvatar, section]); + + return ( + <> + updateLocalAndScheduleGlobal({ isSpeakingTopicOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-speaking` : ''} + > + +
+
+ + +
+ {focusedSection === 1 && +
+ + +
+ } +
+ +
+
+
+ updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)} + > +
+
+ +
+ {selectedAvatar && ( + selectedAvatar.gender === 'male' ? ( + + ) : ( + + ) + )} +
+
+ + +
+
+ + ); +}; + +export default SpeakingComponents; diff --git a/src/components/ExamEditor/SettingsEditor/speaking/index.tsx b/src/components/ExamEditor/SettingsEditor/speaking/index.tsx new file mode 100644 index 00000000..0c36784f --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/speaking/index.tsx @@ -0,0 +1,261 @@ +import useExamEditorStore from "@/stores/examEditor"; +import useSettingsState from "../../Hooks/useSettingsState"; +import { SpeakingSectionSettings } from "@/stores/examEditor/types"; +import Option from "@/interfaces/option"; +import SettingsEditor from ".."; +import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam"; +import { toast } from "react-toastify"; +import { usePersistentExamStore } from "@/stores/examStore"; +import { useRouter } from "next/router"; +import openDetachedTab from "@/utils/popout"; +import axios from "axios"; +import { playSound } from "@/utils/sound"; +import SpeakingComponents from "./components"; + +export interface Avatar { + name: string; + gender: string; +} + +const SpeakingSettings: React.FC = () => { + + const router = useRouter(); + + const { + setExam, + setExerciseIndex, + setQuestionIndex, + setBgColor, + } = usePersistentExamStore(); + + const { title, currentModule } = useExamEditorStore(); + const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule]) + + const section = sections.find((section) => section.sectionId == focusedSection)?.state; + + const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( + currentModule, + focusedSection, + ); + + if (section === undefined) return <>; + + const currentSection = section as SpeakingExercise | InteractiveSpeakingExercise; + + const defaultPresets: Option[] = [ + { + label: "Preset: Speaking Part 1", + value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked." + }, + { + label: "Preset: Speaking Part 2", + value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience." + }, + { + label: "Preset: Speaking Part 3", + value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic." + } + ]; + + const canPreviewOrSubmit = (() => { + return sections.every((s) => { + const section = s.state as SpeakingExercise | InteractiveSpeakingExercise; + switch (section.type) { + case 'speaking': + return section.title !== '' && + section.text !== '' && + section.video_url !== '' && + section.prompts.every(prompt => prompt !== ''); + + case 'interactiveSpeaking': + if ('first_title' in section && 'second_title' in section) { + return section.first_title !== '' && + section.second_title !== '' && + section.prompts.every(prompt => prompt.video_url !== '') && + section.prompts.length > 2; + } + return section.title !== '' && + section.prompts.every(prompt => prompt.video_url !== ''); + + default: + return false; + } + }); + })(); + + const submitSpeaking = async () => { + if (title === "") { + toast.error("Enter a title for the exam!"); + return; + } + + try { + const formData = new FormData(); + const urlMap = new Map(); + + const sectionsWithVideos = sections.filter(s => { + const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; + if (exercise.type === "speaking") { + return exercise.video_url !== ""; + } + if (exercise.type === "interactiveSpeaking") { + return exercise.prompts?.some(prompt => prompt.video_url !== ""); + } + return false; + }); + + if (sectionsWithVideos.length === 0) { + toast.error('No video sections found in the exam! Please record or import videos.'); + return; + } + + await Promise.all( + sectionsWithVideos.map(async (section) => { + const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise; + + if (exercise.type === "speaking") { + const response = await fetch(exercise.video_url); + const blob = await response.blob(); + formData.append('file', blob, 'video.mp4'); + urlMap.set(`${section.sectionId}`, exercise.video_url); + } else { + await Promise.all( + exercise.prompts.map(async (prompt, promptIndex) => { + if (prompt.video_url) { + const response = await fetch(prompt.video_url); + const blob = await response.blob(); + formData.append('file', blob, 'video.mp4'); + urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url); + } + }) + ); + } + }) + ); + + const response = await axios.post('/api/storage', formData, { + params: { + directory: 'speaking_videos' + }, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + const { urls } = response.data; + + const exam: SpeakingExam = { + exercises: sections.map((s) => { + const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; + + if (exercise.type === "speaking") { + const videoIndex = Array.from(urlMap.entries()) + .findIndex(([key]) => key === `${s.sectionId}`); + + return { + ...exercise, + video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url, + intro: s.settings.currentIntro, + category: s.settings.category + }; + } else { + const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => { + const videoIndex = Array.from(urlMap.entries()) + .findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`); + + return { + ...prompt, + video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url + }; + }); + + return { + ...exercise, + prompts: updatedPrompts, + intro: s.settings.currentIntro, + category: s.settings.category + }; + } + }), + minTimer, + module: "speaking", + id: title, + isDiagnostic: false, + variant: undefined, + difficulty, + instructorGender: "varied", + private: isPrivate, + }; + + const result = await axios.post('/api/exam/speaking', exam); + playSound("sent"); + toast.success(`Submitted Exam ID: ${result.data.id}`); + + Array.from(urlMap.values()).forEach(url => { + URL.revokeObjectURL(url); + }); + + } catch (error: any) { + toast.error( + "Something went wrong while submitting, please try again later." + ); + } + }; + + const preview = () => { + setExam({ + exercises: sections + .filter((s) => { + const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; + + if (exercise.type === "speaking") { + return exercise.video_url !== ""; + } + + if (exercise.type === "interactiveSpeaking") { + return exercise.prompts?.every(prompt => prompt.video_url !== ""); + } + + return false; + }) + .map((s) => { + const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise; + return { + ...exercise, + intro: s.settings.currentIntro, + category: s.settings.category + }; + }), + minTimer, + module: "speaking", + id: title, + isDiagnostic: false, + variant: undefined, + difficulty, + private: isPrivate, + } as SpeakingExam); + setExerciseIndex(0); + setQuestionIndex(0); + setBgColor("bg-white"); + openDetachedTab("popout?type=Exam&module=speaking", router) + } + + return ( + + + + ); +}; + +export default SpeakingSettings; diff --git a/src/components/ExamEditor/SettingsEditor/writing/components.tsx b/src/components/ExamEditor/SettingsEditor/writing/components.tsx new file mode 100644 index 00000000..e2c6b0e6 --- /dev/null +++ b/src/components/ExamEditor/SettingsEditor/writing/components.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useState } from "react"; +import Dropdown from "../Shared/SettingsDropdown"; +import Input from "@/components/Low/Input"; +import { generate } from "../Shared/Generate"; +import GenerateBtn from "../Shared/GenerateBtn"; +import { LevelSectionSettings, WritingSectionSettings } from "@/stores/examEditor/types"; +import useExamEditorStore from "@/stores/examEditor"; +import { WritingExercise } from "@/interfaces/exam"; + + +interface Props { + localSettings: WritingSectionSettings | LevelSectionSettings; + updateLocalAndScheduleGlobal: (updates: Partial, schedule?: boolean) => void; + currentSection?: WritingExercise; + level?: boolean; +} + +const WritingComponents: React.FC = ({localSettings, updateLocalAndScheduleGlobal, level}) => { + const { currentModule } = useExamEditorStore(); + const { + difficulty, + focusedSection, + } = useExamEditorStore((store) => store.modules["writing"]); + + const generatePassage = useCallback((sectionId: number) => { + generate( + sectionId, + currentModule, + "writing", + { + method: 'GET', + queryParams: { + difficulty, + ...(localSettings.writingTopic && { topic: localSettings.writingTopic }) + } + }, + (data: any) => [{ + prompt: data.question + }] + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localSettings.writingTopic, difficulty]); + + const onTopicChange = useCallback((writingTopic: string) => { + updateLocalAndScheduleGlobal({ writingTopic }); + }, [updateLocalAndScheduleGlobal]); + + return ( + <> + updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)} + contentWrapperClassName={level ? `border border-ielts-writing`: ''} + > + +
+
+ + +
+
+ +
+
+
+ + ); +}; + +export default WritingComponents; diff --git a/src/components/ExamEditor/SettingsEditor/writing.tsx b/src/components/ExamEditor/SettingsEditor/writing/index.tsx similarity index 63% rename from src/components/ExamEditor/SettingsEditor/writing.tsx rename to src/components/ExamEditor/SettingsEditor/writing/index.tsx index 15efa2d3..424a9a74 100644 --- a/src/components/ExamEditor/SettingsEditor/writing.tsx +++ b/src/components/ExamEditor/SettingsEditor/writing/index.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useEffect, useState } from "react"; -import SettingsEditor from "."; +import SettingsEditor from ".."; import Option from "@/interfaces/option"; -import Dropdown from "./Shared/SettingsDropdown"; +import Dropdown from "../Shared/SettingsDropdown"; import Input from "@/components/Low/Input"; -import { generate } from "./Shared/Generate"; -import useSettingsState from "../Hooks/useSettingsState"; -import GenerateBtn from "./Shared/GenerateBtn"; -import { SectionSettings } from "@/stores/examEditor/types"; +import { generate } from "../Shared/Generate"; +import useSettingsState from "../../Hooks/useSettingsState"; +import GenerateBtn from "../Shared/GenerateBtn"; +import { WritingSectionSettings } from "@/stores/examEditor/types"; import useExamEditorStore from "@/stores/examEditor"; import { useRouter } from "next/router"; import { usePersistentExamStore } from "@/stores/examStore"; @@ -16,6 +16,7 @@ import { v4 } from "uuid"; import axios from "axios"; import { playSound } from "@/utils/sound"; import { toast } from "react-toastify"; +import WritingComponents from "./components"; const WritingSettings: React.FC = () => { const router = useRouter(); @@ -37,7 +38,7 @@ const WritingSettings: React.FC = () => { setExerciseIndex, } = usePersistentExamStore(); - const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( + const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState( currentModule, focusedSection, ); @@ -53,29 +54,6 @@ const WritingSettings: React.FC = () => { } ]; - const generatePassage = useCallback((sectionId: number) => { - generate( - sectionId, - currentModule, - "context", - { - method: 'GET', - queryParams: { - difficulty, - ...(localSettings.topic && { topic: localSettings.topic }) - } - }, - (data: any) => [{ - prompt: data.question - }] - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [localSettings.topic, difficulty]); - - const onTopicChange = useCallback((topic: string) => { - updateLocalAndScheduleGlobal({ topic }); - }, [updateLocalAndScheduleGlobal]); - useEffect(() => { setCanPreviewOrSubmit(states.some((s) => s.prompt !== "")) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -145,36 +123,9 @@ const WritingSettings: React.FC = () => { canSubmit={canPreviewOrSubmit} submitModule={submitWriting} > - updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)} - > - -
-
- - -
-
- -
-
-
+ ); }; diff --git a/src/components/ExamEditor/Shared/ExerciseLabel.tsx b/src/components/ExamEditor/Shared/ExerciseLabel.tsx index e8866d6b..024d0b59 100644 --- a/src/components/ExamEditor/Shared/ExerciseLabel.tsx +++ b/src/components/ExamEditor/Shared/ExerciseLabel.tsx @@ -1,13 +1,26 @@ interface Props { - label: string; - preview?: React.ReactNode; + type: string; + firstId: string; + lastId: string; + prompt: string; } -const ExerciseLabel: React.FC = ({label, preview}) => { +const previewLabel = (text: string) => { + return <> + "{text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""}..." + +} + +const label = (type: string, firstId: string, lastId: string) => { + return `${type} #${firstId} ${firstId === lastId ? '' : `- #${lastId}`}`; +} + + +const ExerciseLabel: React.FC = ({type, firstId, lastId, prompt}) => { return (
- {label} - {preview &&
{preview}
} + {label(type, firstId, lastId)} +
{previewLabel(prompt)}
); } diff --git a/src/components/ExamEditor/Shared/Header.tsx b/src/components/ExamEditor/Shared/Header.tsx index 8de2bb48..3a1e0522 100644 --- a/src/components/ExamEditor/Shared/Header.tsx +++ b/src/components/ExamEditor/Shared/Header.tsx @@ -1,7 +1,8 @@ import { Module } from "@/interfaces"; import clsx from "clsx"; import { ReactNode } from "react"; -import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave, MdGrade, MdOutlineGrade } from "react-icons/md"; +import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave} from "react-icons/md"; +import { HiOutlineClipboardCheck, HiOutlineClipboardList} from "react-icons/hi"; interface Props { title: string; @@ -10,14 +11,15 @@ interface Props { module?: Module; handleSave: () => void; handleDiscard: () => void; - modeHandle?: () => void; - evaluationHandle?: () => void; + handleDelete?: () => void; + handlePractice?: () => void; + handleEdit?: () => void; isEvaluationEnabled?: boolean; - mode?: "delete" | "edit"; children?: ReactNode; } -const Header: React.FC = ({ title, description, editing, isEvaluationEnabled, handleSave, handleDiscard, modeHandle, evaluationHandle, children, mode = "delete", module }) => { +const Header: React.FC = ({ + title, description, editing, isEvaluationEnabled, handleSave, handleDiscard, handleDelete, handleEdit, handlePractice, children, module }) => { return (
@@ -48,26 +50,18 @@ const Header: React.FC = ({ title, description, editing, isEvaluationEnab Discard - {mode === "delete" ? ( + {handleEdit && ( - ) : ( - )} - {mode === "delete" && + {handlePractice && } + {handleDelete && ( + + )}
); diff --git a/src/components/ExamEditor/index.tsx b/src/components/ExamEditor/index.tsx index d72b4667..16616ec0 100644 --- a/src/components/ExamEditor/index.tsx +++ b/src/components/ExamEditor/index.tsx @@ -15,7 +15,7 @@ import ReadingSettings from "./SettingsEditor/reading"; import LevelSettings from "./SettingsEditor/level"; import ListeningSettings from "./SettingsEditor/listening"; import SpeakingSettings from "./SettingsEditor/speaking"; -import ImportOrStartFromScratch from "./Shared/ImportExam/ImportOrFromScratch"; +import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch"; import { defaultSectionSettings } from "@/stores/examEditor/defaults"; const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"]; diff --git a/src/exams/Listening.tsx b/src/exams/Listening.tsx index 3a006586..1e4a9788 100644 --- a/src/exams/Listening.tsx +++ b/src/exams/Listening.tsx @@ -127,7 +127,8 @@ export default function Listening({ exam, showSolutions = false, preview = false (x) => x === 0, ) && !showSolutions && - !hasExamEnded + !hasExamEnded && + !preview ) { setShowBlankModal(true); return; @@ -249,7 +250,7 @@ export default function Listening({ exam, showSolutions = false, preview = false minTimer={exam.minTimer} module="listening" totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} - disableTimer={showSolutions} + disableTimer={showSolutions || preview} /> {/* Audio Player for the Instructions */} {partIndex === -1 && renderAudioInstructionsPlayer()} diff --git a/src/exams/Reading.tsx b/src/exams/Reading.tsx index 759a91c9..4b50cf9e 100644 --- a/src/exams/Reading.tsx +++ b/src/exams/Reading.tsx @@ -216,6 +216,7 @@ export default function Reading({ exam, showSolutions = false, preview = false, (x) => x === 0, ) && !showSolutions && + !preview && !hasExamEnded ) { setShowBlankModal(true); @@ -324,7 +325,7 @@ export default function Reading({ exam, showSolutions = false, preview = false, exerciseIndex={calculateExerciseIndex()} module="reading" totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} - disableTimer={showSolutions} + disableTimer={showSolutions || preview} label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)} />
{ } switch (module) { + case 'writing': + return { + ...baseSettings, + writingTopic: '', + isWritingTopicOpen: false + } case 'reading': return { ...baseSettings, isPassageOpen: false, + readingTopic: '', + isReadingTopicOpean: false, } - case 'listening': + case 'listening': return { ...baseSettings, isAudioContextOpen: false, isAudioGenerationOpen: false, + listeningTopic: '', + isListeningTopicOpen: false, } case 'speaking': return { ...baseSettings, - isGenerateAudioOpen: false + speakingTopic: '', + speakingSecondTopic: '', + isSpeakingTopicOpen: false, + isGenerateVideoOpen: false, + } + case 'level': + return { + ...baseSettings, + isReadingDropdownOpen: false, + isWritingDropdownOpen: false, + isSpeakingDropdownOpen: false, + isListeningDropdownOpen: false, + + isWritingTopicOpen: false, + writingTopic: '', + + isPassageOpen: false, + readingTopic: '', + isReadingTopicOpean: false, + + isAudioContextOpen: false, + isAudioGenerationOpen: false, + listeningTopic: '', + isListeningTopicOpen: false, + + speakingTopic: '', + speakingSecondTopic: '', + isSpeakingTopicOpen: false, + isGenerateVideoOpen: false, } default: return baseSettings; @@ -47,16 +85,16 @@ const sectionLabels = (module: Module) => { label: `Passage ${index + 1}` })); case 'writing': - return [{id: 1, label: "Task 1"}, {id: 2, label: "Task 2"}]; + return [{ id: 1, label: "Task 1" }, { id: 2, label: "Task 2" }]; case 'speaking': - return [{id: 1, label: "Speaking 1"}, {id: 2, label: "Speaking 2"}, {id: 3, label: "Interactive Speaking"}]; + return [{ id: 1, label: "Speaking 1" }, { id: 2, label: "Speaking 2" }, { id: 3, label: "Interactive Speaking" }]; case 'listening': return Array.from({ length: 4 }, (_, index) => ({ id: index + 1, label: `Section ${index + 1}` })); case 'level': - return [{id: 1, label: "Part 1"}]; + return [{ id: 1, label: "Part 1" }]; } } @@ -85,7 +123,7 @@ const defaultSection = (module: Module, sectionId: number) => { return listeningSection(sectionId) case 'speaking': return speakingTask(sectionId) - case 'level': + case 'level': return levelPart(sectionId) } } @@ -99,8 +137,8 @@ export const defaultSectionSettings = (module: Module, sectionId: number, part?: generating: undefined, genResult: undefined, expandedSubSections: [], - exercisePickerState: [], - selectedExercises: [], + levelGenerating: [], + levelGenResults: [] } } diff --git a/src/stores/examEditor/reducers/index.ts b/src/stores/examEditor/reducers/index.ts index a229972a..998a9556 100644 --- a/src/stores/examEditor/reducers/index.ts +++ b/src/stores/examEditor/reducers/index.ts @@ -17,6 +17,7 @@ export const rootReducer = ( state: ExamEditorStore, action: Action ): Partial => { + console.log(action); if (MODULE_ACTIONS.includes(action.type as any)) { if (action.type === "REORDER_EXERCISES") { const updatedState = sectionReducer(state, action as SectionActions); diff --git a/src/stores/examEditor/reducers/moduleReducer.ts b/src/stores/examEditor/reducers/moduleReducer.ts index af81fb65..2d8b5430 100644 --- a/src/stores/examEditor/reducers/moduleReducer.ts +++ b/src/stores/examEditor/reducers/moduleReducer.ts @@ -51,6 +51,7 @@ export const moduleReducer = ( case 'TOGGLE_SECTION': const { sectionId } = action.payload; + console.log("TOGGLE SECTION TRIGGERED"); const prev = currentModuleState.sections; const updatedSections = prev.some(section => section.sectionId === sectionId) diff --git a/src/stores/examEditor/reducers/sectionReducer.ts b/src/stores/examEditor/reducers/sectionReducer.ts index 37a09a03..b5a422c0 100644 --- a/src/stores/examEditor/reducers/sectionReducer.ts +++ b/src/stores/examEditor/reducers/sectionReducer.ts @@ -6,9 +6,9 @@ import { reorderSection } from "../reorder/global"; export type SectionActions = | { type: 'UPDATE_SECTION_SINGLE_FIELD'; payload: { module: Module; sectionId: number; field: string; value: any } } - | { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; update: Partial; } } - | { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; update: Partial
; } } - | { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, sectionId: number; } }; + | { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; module: Module; update: Partial; } } + | { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; module: Module; update: Partial
; } } + | { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, module: Module; sectionId: number; } }; export const SECTION_ACTIONS = [ 'UPDATE_SECTION_SETTINGS', @@ -20,24 +20,16 @@ export const sectionReducer = ( state: ExamEditorStore, action: SectionActions ): Partial => { - const currentModule = state.currentModule; - const modules = state.modules; - const sections = state.modules[currentModule].sections; - let sectionId: number; - - if (action.payload && 'sectionId' in action.payload) { - sectionId = action.payload.sectionId; - } - switch (action.type) { - case 'UPDATE_SECTION_SINGLE_FIELD': - const { module, field, value } = action.payload; + case 'UPDATE_SECTION_SINGLE_FIELD':{ + const { module, field, value, sectionId } = action.payload; + console.log(`Updating ${module}-${sectionId} ${field} to ${value}`); return { modules: { - ...modules, + ...state.modules, [module]: { - ...modules[module], - sections: sections.map((section: SectionState) => + ...state.modules[module], + sections: state.modules[module].sections.map((section: SectionState) => section.sectionId === sectionId ? { ...section, @@ -48,22 +40,21 @@ export const sectionReducer = ( } } }; - - - case 'UPDATE_SECTION_SETTINGS': - let updatedSettings = action.payload.update; + } + case 'UPDATE_SECTION_SETTINGS':{ + const {module, sectionId, update} = action.payload; return { modules: { - ...modules, - [currentModule]: { - ...modules[currentModule], - sections: sections.map((section: SectionState) => + ...state.modules, + [module]: { + ...state.modules[module], + sections: state.modules[module].sections.map((section: SectionState) => section.sectionId === sectionId ? { ...section, settings: { ...section.settings, - ...updatedSettings + ...update } } : section @@ -71,30 +62,32 @@ export const sectionReducer = ( } } }; - - case 'UPDATE_SECTION_STATE': - const updatedState = action.payload.update; + } + case 'UPDATE_SECTION_STATE': { + const { update, module, sectionId }= action.payload; return { modules: { - ...modules, - [currentModule]: { - ...modules[currentModule], - sections: sections.map(section => + ...state.modules, + [module]: { + ...state.modules[module], + sections: state.modules[module].sections.map(section => section.sectionId === sectionId - ? { ...section, state: { ...section.state, ...updatedState } } + ? { ...section, state: { ...section.state, ...update } } : section ) } } }; + } case 'REORDER_EXERCISES': { - const { active, over } = action.payload.event; + const { event, sectionId, module } = action.payload; + const {over, active} = event; if (!over) return state; const oldIndex = active.id as number; const newIndex = over.id as number; - const currentSectionState = sections.find((s) => s.sectionId === sectionId)!.state as ReadingPart | ListeningPart | LevelPart; + const currentSectionState = state.modules[module].sections.find((s) => s.sectionId === sectionId)!.state as ReadingPart | ListeningPart | LevelPart; const exercises = [...currentSectionState.exercises]; const [removed] = exercises.splice(oldIndex, 1); exercises.splice(newIndex, 0, removed); @@ -110,9 +103,9 @@ export const sectionReducer = ( ...state, modules: { ...state.modules, - [currentModule]: { - ...modules[currentModule], - sections: sections.map(section => + [module]: { + ...state.modules[module], + sections: state.modules[module].sections.map(section => section.sectionId === sectionId ? { ...section, state: newSectionState } : section diff --git a/src/stores/examEditor/reorder/global.ts b/src/stores/examEditor/reorder/global.ts index 12dbdff9..801943cb 100644 --- a/src/stores/examEditor/reorder/global.ts +++ b/src/stores/examEditor/reorder/global.ts @@ -1,4 +1,4 @@ -import { Exercise, FillBlanksExercise, LevelPart, ListeningPart, MatchSentencesExercise, MultipleChoiceExercise, ReadingPart, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam"; +import { Exercise, FillBlanksExercise, LevelPart, ListeningPart, MatchSentencesExercise, MultipleChoiceExercise, ReadingPart, TrueFalseExercise, WriteBlanksExercise, WritingExercise } from "@/interfaces/exam"; import { ModuleState } from "../types"; import ReorderResult from "./types"; @@ -143,7 +143,6 @@ const reorderSection = (exercises: Exercise[], startId: number): { exercises: Ex switch (exercise.type) { case 'fillBlanks': - console.log("Reordering FillBlanks"); result = reorderFillBlanks(exercise, currentId); currentId = result.lastId; return result.exercise; @@ -168,7 +167,12 @@ const reorderSection = (exercises: Exercise[], startId: number): { exercises: Ex currentId = result.lastId; return result.exercise; + case 'writing': + exercise = { ...exercise, sectionId: currentId }; + currentId += 1; + return exercise; default: + console.log("HERE IT IS"); return exercise; } }); diff --git a/src/stores/examEditor/sections.ts b/src/stores/examEditor/sections.ts index 4f713fa1..5a25e547 100644 --- a/src/stores/examEditor/sections.ts +++ b/src/stores/examEditor/sections.ts @@ -11,7 +11,8 @@ export const writingTask = (task: number) => ({ wordCounter: { limit: task == 1 ? 150 : 250, type: "min", - } + }, + order: 1 } as WritingExercise); export const readingPart = (task: number) => { diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index 77494e66..8d481600 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -1,7 +1,7 @@ import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { Module } from "@/interfaces"; import Option from "@/interfaces/option"; -import { ExerciseConfig } from "@/components/ExamEditor/Shared/ExercisePicker/ExerciseWizard"; +import { ExerciseConfig } from "@/components/ExamEditor/ExercisePicker/ExerciseWizard"; export interface GeneratedExercises { exercises: Record[]; @@ -16,32 +16,70 @@ export interface SectionSettings { currentIntro: string | undefined; isCategoryDropdownOpen: boolean; isIntroDropdownOpen: boolean; - isExerciseDropdownOpen: boolean; - topic?: string; } export interface SpeakingSectionSettings extends SectionSettings { - secondTopic?: string; - isGenerateAudioOpen: boolean; + speakingTopic: string; + speakingSecondTopic?: string; + isSpeakingTopicOpen: boolean; + isGenerateVideoOpen: boolean; } export interface ReadingSectionSettings extends SectionSettings { isPassageOpen: boolean; + readingTopic: string; + isReadingTopicOpean: boolean; } export interface ListeningSectionSettings extends SectionSettings { isAudioContextOpen: boolean; isAudioGenerationOpen: boolean; + listeningTopic: string; + isListeningTopicOpen: boolean; +} + +export interface WritingSectionSettings extends SectionSettings { + isWritingTopicOpen: boolean; + writingTopic: string; } export interface LevelSectionSettings extends SectionSettings { - readingDropdownOpen: boolean; - writingDropdownOpen: boolean; - speakingDropdownOpen: boolean; - listeningDropdownOpen: boolean; + isReadingDropdownOpen: boolean; + isWritingDropdownOpen: boolean; + isSpeakingDropdownOpen: boolean; + isListeningDropdownOpen: boolean; + isLevelDropdownOpen: boolean; + readingSection?: number; + listeningSection?: number; + + // writing + isWritingTopicOpen: boolean; + writingTopic: string; + + // reading + isPassageOpen: boolean; + readingTopic: string; + isReadingTopicOpean: boolean; + + // listening + isAudioContextOpen: boolean; + isAudioGenerationOpen: boolean; + listeningTopic: string; + isListeningTopicOpen: boolean; + + // speaking + speakingTopic?: string; + speakingSecondTopic?: string; + isSpeakingTopicOpen: boolean; + isGenerateVideoOpen: boolean; + + // section picker + isReadingPickerOpen: boolean; + isListeningPickerOpen: boolean; } -export type Generating = "context" | "exercises" | "media" | undefined; +export type Context = "passage" | "video" | "audio" | "listeningScript" | "speakingScript" | "writing"; +export type Generating = Context | "exercises" | string | undefined; export type Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise; export type ExamPart = ListeningPart | ReadingPart | LevelPart; @@ -51,9 +89,14 @@ export interface SectionState { state: Section; expandedSubSections: number[]; generating: Generating; - genResult: Record[] | undefined; - exercisePickerState: ExerciseConfig[]; - selectedExercises: string[]; + genResult: {generating: string, result: Record[], module: Module} | undefined; + levelGenerating: Generating[]; + levelGenResults: {generating: string, result: Record[], module: Module}[]; + focusedExercise?: number; + writingSection?: number; + speakingSection?: number; + readingSection?: number; + listeningSection?: number; } export interface ModuleState {