Merged in feature/ExamGenRework (pull request #109)

Feature/ExamGenRework

Approved-by: Tiago Ribeiro
This commit is contained in:
carlos.mesquita
2024-11-12 17:00:12 +00:00
committed by Tiago Ribeiro
64 changed files with 2544 additions and 1637 deletions

View File

@@ -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<string, any>;
onSubmit: (configurations: ExerciseConfig[]) => void;
onDiscard: () => void;
selectedExercises: string[];
}
export interface ExerciseConfig {
@@ -24,15 +27,14 @@ export interface ExerciseConfig {
}
const ExerciseWizard: React.FC<Props> = ({
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<ExerciseConfig[]>([]);
useEffect(() => {
@@ -236,7 +238,7 @@ const ExerciseWizard: React.FC<Props> = ({
return (
<div
key={config.type}
className={`bg-ielts-${currentModule}/70 text-white rounded-lg p-4 shadow-xl`}
className={`bg-ielts-${module}/70 text-white rounded-lg p-4 shadow-xl`}
>
{renderExerciseHeader(exercise, exerciseIndex, config, (exercise.extra || []).length > 2)}
@@ -262,7 +264,7 @@ const ExerciseWizard: React.FC<Props> = ({
</button>
<button
onClick={() => onSubmit(configurations)}
className={`px-4 py-2 bg-ielts-${currentModule} text-white rounded-md hover:bg-ielts-${currentModule}/80 transition-colors`}
className={`px-4 py-2 bg-ielts-${module} text-white rounded-md hover:bg-ielts-${module}/80 transition-colors`}
>
Add Exercises
</button>

View File

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

View File

@@ -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<string, any>;
levelSectionId?: number;
level?: boolean;
}
const ExercisePicker: React.FC<ExercisePickerProps> = ({
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<string[]>([]);
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<ExercisePickerProps> = ({
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,40 +72,31 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
};
});
let context, moduleState;
switch (module) {
case 'reading':
moduleState = state as ReadingPart;
let context = {};
if (module === 'reading') {
const readingState = state as ReadingPart | LevelPart;
context = {
text: moduleState.text.content
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: dialog.map((d) => `${d.name}: ${d.text}`).join("\n")
};
} else if (sectionId === 2 || sectionId === 4) {
context = {
text: script as string
};
}
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 = {}
}
if (!["speaking", "writing"].includes(module)) {
generate(
sectionId,
module as Module,
"exercises",
level ? `exercises-${module}` : "exercises",
{
method: 'POST',
body: {
@@ -112,12 +107,62 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
},
(data: any) => [{
exercises: data.exercises
}]
}],
levelSectionId,
level
);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "selectedExercises", value: [] } })
} 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 };
}
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<ExercisePickerProps> = ({
)}
>
<ExerciseWizard
module={module as Module}
selectedExercises={localSelectedExercises}
sectionId={sectionId}
exercises={moduleExercises}
onSubmit={onModuleSpecific}
@@ -151,7 +198,7 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
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<ExercisePickerProps> = ({
)
}
onClick={() => setPickerOpen(true)}
disabled={selectedExercises.length == 0}
disabled={localSelectedExercises.length === 0}
>
{section.generating === "exercises" ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
<>Set Up Exercises ({selectedExercises.length}) </>
<>{["speaking", "writing"].includes(module) ? "Add Exercises" : "Set Up Exercises"} ({localSelectedExercises.length}) </>
)}
</button>
</div>

View File

@@ -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: !local.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 } });
}
});
@@ -231,8 +242,6 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, blanksState.blanks, blanksState.textMode])
useEffect(()=> {
setEditingAlert(editing, setAlerts);
}, [editing])
@@ -252,8 +261,10 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
onBlankRemove={handleBlankRemove}
onSave={handleSave}
onDiscard={handleDiscard}
onDelete={modeHandle}
onDelete={handleDelete}
setEditing={setEditing}
onPractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
>
<>
{!blanksState.textMode && <Card className="p-4">

View File

@@ -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: !local.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={!local.isPractice}
>
{!blanksState.textMode && selectedBlankId && (
<Card className="p-4">

View File

@@ -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: !local.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 } });
}
});
@@ -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={!local.isPractice}
>
{!blanksState.textMode && (
<Card>

View File

@@ -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<Props> = ({
onSave,
onDiscard,
onDelete,
onPractice,
isEvaluationEnabled,
setEditing
}) => {
@@ -161,8 +165,10 @@ const BlanksEditor: React.FC<Props> = ({
description={description}
editing={editing}
handleSave={onSave}
modeHandle={onDelete}
handleDelete={onDelete}
handleDiscard={onDiscard}
handlePractice={onPractice}
isEvaluationEnabled={isEvaluationEnabled}
/>
{alerts.length > 0 && <Alert alerts={alerts} />}
<Card>

View File

@@ -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: !local.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={!local.isPractice}
>
<button
onClick={() => setShowReference(!showReference)}
@@ -210,7 +223,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
</SortableQuestion>
))}
</QuestionsList>
{(section.text.content.split("\n\n").length - 1) === local.sentences.length && (
{(section.text !== undefined && section.text.content.split("\n\n").length - 1) === local.sentences.length && (
<button
onClick={addHeading}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"

View File

@@ -73,9 +73,8 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
updateLocal({ ...local, questions: newQuestions });
};
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
setEditing(false);
setAlerts([]);
@@ -85,20 +84,31 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
ex.id === local.id ? local : ex
)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setAlerts([]);
setLocal(exercise);
setEditing(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: !local.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 } });
}
});
return (
@@ -108,8 +118,10 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
description="Edit questions with 4 underline options each"
editing={editing}
handleSave={handleSave}
modeHandle={modeHandle}
handleDelete={handleDelete}
handlePractice={handlePractice}
handleDiscard={handleDiscard}
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}

View File

@@ -143,9 +143,8 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
updateLocal({ ...local, questions: updatedQuestions });
};
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
const isValid = validateMultipleChoiceQuestions(
local.questions,
@@ -164,20 +163,31 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setEditing(false);
setAlerts([]);
setLocal(exercise);
},
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: !local.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 } });
}
});
useEffect(() => {
@@ -197,8 +207,10 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
description={`Edit questions with ${optionsQuantity} options each`}
editing={editing}
handleSave={handleSave}
modeHandle={modeHandle}
handleDelete={handleDelete}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<Card className="mb-6">

View File

@@ -41,7 +41,6 @@ const colorOptions = [
];
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
const isConversation = [1, 3].includes(section);
const speakerCount = section === 1 ? 2 : 4;

View File

@@ -12,57 +12,74 @@ import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
import { RiVideoLine } from "react-icons/ri";
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: InteractiveSpeakingExercise;
module?: Module;
}
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
const { currentModule, dispatch } = useExamEditorStore();
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const { dispatch } = useExamEditorStore();
const [local, setLocal] = useState(exercise);
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const { generating, genResult } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
const { generating, genResult, state } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
setEditing(false);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
},
onDiscard: () => {
setLocal(exercise);
},
onMode: () => { },
});
useEffect(() => {
if (genResult && generating === "context") {
setEditing(true);
setLocal({
...local,
title: genResult[0].title,
prompts: genResult[0].prompts.map((item: any) => ({
text: item || "",
video_url: ""
}))
});
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local, module: module } });
if (genResult) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
module: module,
field: "genResult",
value: undefined
}
});
}
},
onDiscard: () => {
setLocal(exercise);
},
onPractice: () => {
const updatedExercise = {
...state,
isPractice: !local.isPractice
};
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } });
},
});
useEffect(() => {
if (genResult && generating === "speakingScript") {
setEditing(true);
setLocal({
...local,
title: genResult.result[0].title,
prompts: genResult.result[0].prompts.map((item: any) => ({
text: item || "",
video_url: ""
}))
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: module,
field: "generating",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
@@ -91,27 +108,18 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
const isUnedited = local.prompts.length === 0;
useEffect(() => {
if (genResult && generating === "media") {
setLocal({ ...local, prompts: genResult[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } });
if (genResult && generating === "video") {
setLocal({ ...local, prompts: genResult.result[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module: module } });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
module: module,
field: "generating",
value: undefined
}
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "genResult",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
@@ -134,14 +142,15 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
description='Generate or write the scripts for the videos.'
editing={editing}
handleSave={handleSave}
modeHandle={modeHandle}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
mode="edit"
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module="speaking"
/>
</div>
{generating && generating === "context" ? (
<GenLoader module={currentModule} />
{generating && generating === "speakingScript" ? (
<GenLoader module={module} />
) : (
<>
{editing ? (
@@ -196,8 +205,8 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
</CardContent>
</Card>
)}
{generating && generating === "media" &&
<GenLoader module={currentModule} custom="Generating the videos ... This may take a while ..." />
{generating && generating === "video" &&
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
}
<Card>
<CardContent>

View File

@@ -10,14 +10,16 @@ import { InteractiveSpeakingExercise } from "@/interfaces/exam";
import { BsFileText } from "react-icons/bs";
import { RiVideoLine } from 'react-icons/ri';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
import { Module } from '@/interfaces';
interface Props {
sectionId: number;
exercise: InteractiveSpeakingExercise;
module?: Module;
}
const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
const { currentModule, dispatch } = useExamEditorStore();
const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const {dispatch} = useExamEditorStore();
const [local, setLocal] = useState(() => {
const defaultPrompts = [
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
@@ -29,16 +31,26 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const { generating, genResult } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
const { generating, genResult , state} = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
setEditing(false);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local, module } });
if (genResult) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: module,
field: "genResult",
value: undefined
}
});
}
},
onDiscard: () => {
setLocal({
@@ -50,32 +62,37 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
]
});
},
onMode: () => { },
onPractice: () => {
const updatedExercise = {
...state,
isPractice: !local.isPractice
};
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } });
},
});
useEffect(() => {
if (genResult && generating === "context") {
if (genResult && generating === "speakingScript") {
setEditing(true);
setLocal(prev => ({
...prev,
first_title: genResult[0].first_topic,
second_title: genResult[0].second_topic,
first_title: genResult.result[0].first_topic,
second_title: genResult.result[0].second_topic,
prompts: [
prev.prompts[0],
prev.prompts[1],
...genResult[0].prompts.map((item: any) => ({
...genResult.result[0].prompts.map((item: any) => ({
text: item,
video_url: ""
}))
]
}));
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "genResult",
module: module,
field: "generating",
value: undefined
}
});
@@ -111,15 +128,14 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
useEffect(() => {
if (genResult && generating === "media") {
console.log(genResult[0].prompts);
setLocal({ ...local, prompts: genResult[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } });
if (genResult && generating === "video") {
setLocal({ ...local, prompts: genResult.result[0].prompts });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module } });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
module: module,
field: "generating",
value: undefined
}
@@ -128,7 +144,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
module: module,
field: "genResult",
value: undefined
}
@@ -155,14 +171,15 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
description='Generate or write the scripts for the videos.'
editing={editing}
handleSave={handleSave}
modeHandle={modeHandle}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
mode="edit"
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module="speaking"
/>
</div>
{generating && generating === "context" ? (
<GenLoader module={currentModule} />
{generating && generating === "speakingScript" ? (
<GenLoader module={module} />
) : (
<>
{editing ? (
@@ -305,8 +322,8 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
</CardContent>
</Card>
)}
{generating && generating === "media" &&
<GenLoader module={currentModule} custom="Generating the videos ... This may take a while ..." />
{generating && generating === "video" &&
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
}
<Card>
<CardContent className="pt-6">

View File

@@ -12,52 +12,69 @@ import { AiOutlineUnorderedList } from 'react-icons/ai';
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
import GenLoader from "../Shared/GenLoader";
import { RiVideoLine } from 'react-icons/ri';
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: SpeakingExercise;
module?: Module;
}
const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
const { currentModule, dispatch } = useExamEditorStore();
const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
const { dispatch } = useExamEditorStore();
const [local, setLocal] = useState(exercise);
const { generating, genResult } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
const { generating, genResult, state } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
setEditing(false);
console.log(local);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local , module} });
if (genResult) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: module,
field: "genResult",
value: undefined
}
});
}
},
onDiscard: () => {
setLocal(exercise);
},
onMode: () => { },
onPractice: () => {
const updatedExercise = {
...state,
isPractice: !local.isPractice
};
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } });
},
});
useEffect(() => {
if (genResult && generating === "context") {
if (genResult && generating === "speakingScript") {
setEditing(true);
setLocal({
...local,
title: genResult[0].topic,
text: genResult[0].question,
prompts: genResult[0].prompts
title: genResult.result[0].topic,
text: genResult.result[0].question,
prompts: genResult.result[0].prompts
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "genResult",
module: module,
field: "generating",
value: undefined
}
});
@@ -66,27 +83,18 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
}, [genResult, generating]);
useEffect(() => {
if (genResult && generating === "media") {
setLocal({...local, video_url: genResult[0].video_url});
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult[0].video_url} } });
if (genResult && generating === "video") {
setLocal({...local, video_url: genResult.result[0].video_url});
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult.result[0].video_url} , module} });
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
module: module,
field: "generating",
value: undefined
}
});
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "genResult",
value: undefined
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, generating]);
@@ -138,14 +146,15 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
description='Generate or write the script for the video.'
editing={editing}
handleSave={handleSave}
modeHandle={modeHandle}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
mode="edit"
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module="speaking"
/>
</div>
{generating && generating === "context" ? (
<GenLoader module={currentModule} />
{generating && generating === "speakingScript" ? (
<GenLoader module={module} />
) : (
<>
{editing ? (
@@ -260,8 +269,8 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
</CardContent>
</Card>
}
{generating && generating === "media" &&
<GenLoader module={currentModule} custom="Generating the video ... This may take a while ..." />
{generating && generating === "video" &&
<GenLoader module={module} custom="Generating the video ... This may take a while ..." />
}
<Card>
<CardContent className="pt-6">

View File

@@ -1,12 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import { ModuleState } from "@/stores/examEditor/types";
import { SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
import useSectionEdit from "../../Hooks/useSectionEdit";
import Header from "../../Shared/Header";
import GenLoader from "../Shared/GenLoader";
import { Card, CardContent } from "@/components/ui/card";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import Speaking2 from "./Speaking2";
import InteractiveSpeaking from "./InteractiveSpeaking";
import Speaking1 from "./Speaking1";

View File

@@ -72,9 +72,8 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
updateLocal({ ...local, questions: updatedQuestions });
};
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
const isValid = validateTrueFalseQuestions(
local.questions,
@@ -93,18 +92,29 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
},
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: !local.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 } });
}
});
useEffect(() => {
@@ -127,8 +137,9 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
description='Edit questions and their solutions'
editing={editing}
handleSave={handleSave}
modeHandle={modeHandle}
handleDelete={handleDelete}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit

View File

@@ -23,7 +23,7 @@ import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
import { ParsedQuestion, parseText, reconstructText } from './parsing';
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; title?: string; }> = ({ sectionId, exercise, title = "Write Blanks Exercise" }) => {
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
const { currentModule, dispatch } = useExamEditorStore();
const { state } = useExamEditorStore(
@@ -38,9 +38,8 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
const isQuestionTextValid = validateQuestionText(
parsedQuestions,
@@ -65,18 +64,29 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
},
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: !local.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 } });
}
});
useEffect(() => {
@@ -222,12 +232,14 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
return (
<div className="p-4">
<Header
title={title}
title={"Write Blanks: Questions"}
description="Edit questions and their solutions"
editing={editing}
handleSave={handleSave}
handleDiscard={handleDiscard}
modeHandle={modeHandle}
handleDelete={handleDelete}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}

View File

@@ -31,9 +31,8 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
const [editingPrompt, setEditingPrompt] = useState(false);
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
@@ -50,19 +49,30 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection } });
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
setParsedQuestions([]);
},
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: !local.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 } });
}
});
useEffect(() => {
@@ -204,7 +214,8 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
editing={editing}
handleSave={handleSave}
handleDiscard={handleDiscard}
modeHandle={modeHandle}
handleDelete={handleDelete}
handlePractice={handlePractice}
/>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}

View File

@@ -2,22 +2,24 @@ import { useCallback, useEffect, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore, { ModuleState } from "@/stores/examEditor/types";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { WritingExercise } from "@/interfaces/exam";
import { LevelPart, WritingExercise } from "@/interfaces/exam";
import Header from "../../Shared/Header";
import Alert, { AlertItem } from "../Shared/Alert";
import clsx from "clsx";
import useSectionEdit from "../../Hooks/useSectionEdit";
import GenLoader from "../Shared/GenLoader";
import setEditingAlert from "../Shared/setEditingAlert";
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: WritingExercise;
module: Module;
index?: number;
}
const Writing: React.FC<Props> = ({ sectionId, exercise }) => {
const Writing: React.FC<Props> = ({ sectionId, exercise, module, index }) => {
const { currentModule, dispatch } = useExamEditorStore();
const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
const { generating, genResult, state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
@@ -27,42 +29,60 @@ const Writing: React.FC<Props> = ({ sectionId, exercise }) => {
const [loading, setLoading] = useState(generating && generating == "exercises");
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateModule = useCallback((updates: Partial<ModuleState>) => {
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
}, [dispatch]);
const level = module === "level";
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, handleEdit, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const newExercise = { ...local } as WritingExercise;
newExercise.prompt = prompt;
setAlerts([]);
setEditing(false);
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise } });
if (!level) {
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise, module } });
}
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
}
},
onDiscard: () => {
setEditing(false);
setLocal(exercise);
setPrompt(exercise.prompt);
},
onDelete: () => {
if (level) {
dispatch({
type: "UPDATE_SECTION_STATE", payload: {
sectionId: sectionId,
update: {
exercises: (state as LevelPart).exercises.filter((_, i) => i !== index)
},
module
}
});
}
},
onPractice: () => {
const newState = {
...state,
isPractice: !local.isPractice
};
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
const loading = generating && generating == "context";
const loading = generating && generating == "writing";
setLoading(loading);
if (loading) {
updateModule({ edit: Array.from(new Set(edit).add(sectionId)) });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, updateModule]);
}, [generating]);
useEffect(() => {
if (genResult !== undefined && generating === "context") {
if (genResult) {
setEditing(true);
setPrompt(genResult[0].prompt);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
setPrompt(genResult.result[0].prompt);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
@@ -73,20 +93,22 @@ const Writing: React.FC<Props> = ({ sectionId, exercise }) => {
return (
<>
<div className='relative pb-4'>
<div className={clsx('relative', level ? "px-4 mt-2" : "pb-2")}>
<Header
title={`Task ${sectionId} Instructions`}
title={`${sectionId === 1 ? "Letter" : "Essay"} Instructions`}
description='Generate or edit the instructions for the task'
editing={editing}
handleSave={handleSave}
modeHandle={modeHandle}
handleDelete={handleDelete}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
mode="edit"
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module={"writing"}
/>
{alerts.length !== 0 && <Alert alerts={alerts} />}
</div>
<div className="mt-4">
<div className={clsx(level ? "mt-2 px-4" : "mt-4")}>
{loading ?
<GenLoader module={currentModule} /> :
(

View File

@@ -4,12 +4,13 @@ import { useCallback, useState } from 'react';
interface Props {
sectionId: number;
mode?: "delete" | "edit";
editing?: boolean;
setEditing?: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onDiscard?: () => void;
onMode?: () => void;
onDelete?: () => void;
onPractice?: () => void;
onEdit?: () => void;
}
const useSectionEdit = ({
@@ -18,7 +19,9 @@ const useSectionEdit = ({
setEditing: externalSetEditing,
onSave,
onDiscard,
onMode
onDelete,
onPractice,
onEdit
}: Props) => {
const { dispatch } = useExamEditorStore();
const [internalEditing, setInternalEditing] = useState<boolean>(externalEditing);
@@ -31,9 +34,12 @@ const useSectionEdit = ({
}, [dispatch]);
const handleEdit = useCallback(() => {
setEditing(true);
setEditing(!editing);
if (onEdit) {
onEdit();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionId, setEditing, updateRoot]);
}, [sectionId, editing, setEditing, updateRoot]);
const handleSave = useCallback(() => {
if (onSave) {
@@ -51,12 +57,18 @@ const useSectionEdit = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, updateRoot, onDiscard, sectionId]);
const modeHandle = useCallback(() => {
const handleDelete = useCallback(() => {
setEditing(!editing);
onMode?.();
onDelete?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, editing, updateRoot, onMode, sectionId]);
}, [setEditing, editing, updateRoot, onDelete, sectionId]);
const handlePractice = useCallback(() => {
onPractice?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, editing, updateRoot, onPractice, sectionId]);
return {
editing,
@@ -64,7 +76,8 @@ const useSectionEdit = ({
handleEdit,
handleSave,
handleDiscard,
modeHandle,
handleDelete,
handlePractice,
};
};

View File

@@ -38,13 +38,14 @@ const useSettingsState = <T extends SectionSettings>(
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: 'UPDATE_SECTION_SETTINGS',
payload: { sectionId, update: pendingUpdatesRef.current}
payload: { sectionId, update: pendingUpdatesRef.current, module}
});
pendingUpdatesRef.current = {};
}
}, 1000);
return debouncedFn;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, sectionId]);
useEffect(() => {
@@ -52,7 +53,7 @@ const useSettingsState = <T extends SectionSettings>(
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: 'UPDATE_SECTION_SETTINGS',
payload: {sectionId, update: pendingUpdatesRef.current}
payload: {sectionId, update: pendingUpdatesRef.current, module}
});
}
};

View File

@@ -3,7 +3,7 @@ import { FaPencilAlt } from 'react-icons/fa';
import { Module } from '@/interfaces';
import clsx from 'clsx';
import WordUploader from './WordUploader';
import GenLoader from '../../Exercises/Shared/GenLoader';
import GenLoader from '../Exercises/Shared/GenLoader';
import useExamEditorStore from '@/stores/examEditor';
const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => {

View File

@@ -1,40 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore from "@/stores/examEditor/types";
import ExamEditorStore, { Generating } from "@/stores/examEditor/types";
import Header from "../../Shared/Header";
import { Module } from "@/interfaces";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
sectionId: number;
title: string;
description: string;
editing: boolean;
renderContent: (editing: boolean) => React.ReactNode;
renderContent: (editing: boolean, listeningSection?: number) => React.ReactNode;
mode?: "edit" | "delete";
onSave: () => void;
onDiscard: () => void;
onEdit?: () => void;
module?: Module;
module: Module;
listeningSection?: number;
context: Generating;
}
const SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module}) => {
const { currentModule, dispatch } = useExamEditorStore();
const { generating } = useExamEditorStore(
const SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context, listeningSection }) => {
const { currentModule } = useExamEditorStore();
const { generating, levelGenerating } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const [loading, setLoading] = useState(generating && generating == "context");
const updateRoot = useCallback((updates: Partial<ExamEditorStore>) => {
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
}, [dispatch]);
const [loading, setLoading] = useState(generating && generating === context);
useEffect(() => {
const loading = generating && generating == "context";
setLoading(loading);
const gen = module === "level" ? levelGenerating.find(g => g === context) !== undefined : generating && generating === context;
if (loading !== gen) {
setLoading(gen);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, updateRoot]);
}, [generating, levelGenerating]);
return (
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
@@ -45,21 +46,15 @@ const SectionContext: React.FC<Props> = ({ sectionId, title, description, render
editing={editing}
handleSave={onSave}
handleDiscard={onDiscard}
modeHandle={onEdit}
mode={mode}
handleEdit={onEdit}
module={module}
/>
</div>
<div className="mt-4">
{loading ? (
<div className="w-full cursor-text px-7 py-8 border-2 border-mti-gray-platinum bg-white rounded-3xl">
<div className="flex flex-col items-center justify-center animate-pulse">
<span className={`loading loading-infinity w-32 bg-ielts-${currentModule}`} />
<span className={`font-bold text-2xl text-ielts-${currentModule}`}>Generating...</span>
</div>
</div>
<GenLoader module={module} />
) : (
renderContent(editing)
renderContent(editing, listeningSection)
)}
</div>
</div>

View File

@@ -0,0 +1,33 @@
import useExamEditorStore from "@/stores/examEditor";
import ListeningContext from "./listening";
import ReadingContext from "./reading";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
sectionId: number;
}
const LevelContext: React.FC<Props> = ({ sectionId }) => {
const { currentModule } = useExamEditorStore();
const { generating, readingSection, listeningSection } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
return (
<>
{generating && (
(generating === "passage" && <GenLoader module="reading" />) ||
(generating === "listeningScript" && <GenLoader module="listening" />)
)}
{(readingSection || listeningSection) && (
<div className="space-y-4 mb-4">
{readingSection && <ReadingContext sectionId={sectionId} module="level" />}
{listeningSection && <ListeningContext sectionId={sectionId} listeningSection={listeningSection} module="level" level={true}/>}
</div>
)}
</>
);
};
export default LevelContext;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { ListeningPart } from "@/interfaces/exam";
import { LevelPart, ListeningPart } from "@/interfaces/exam";
import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor";
import useSectionEdit from "../../Hooks/useSectionEdit";
@@ -9,25 +9,42 @@ import Dropdown from "@/components/Dropdown";
import AudioPlayer from "@/components/Low/AudioPlayer";
import { MdHeadphones } from "react-icons/md";
import clsx from "clsx";
import { Module } from "@/interfaces";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
module: Module;
sectionId: number;
listeningSection?: number;
level?: boolean;
}
const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const { genResult, state, generating } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection, level = false }) => {
const { dispatch } = useExamEditorStore();
const { genResult, state, generating, levelGenResults, levelGenerating } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const listeningPart = state as ListeningPart;
const listeningPart = state as ListeningPart | LevelPart;
const [isDialogDropdownOpen, setIsDialogDropdownOpen] = useState(false);
const [scriptLocal, setScriptLocal] = useState(listeningPart.script);
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, setEditing, handleEdit } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
const newState = { ...listeningPart };
newState.script = scriptLocal;
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } })
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
setEditing(false);
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "genResult", value: undefined } })
}
if (levelGenResults.find((res) => res.generating === "listeningScript")) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenResults", value: levelGenResults.filter((res) => res.generating !== "listeningScript") } })
}
},
onDiscard: () => {
setScriptLocal(listeningPart.script);
@@ -35,15 +52,42 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
});
useEffect(() => {
if (genResult !== undefined && generating === "context") {
if (genResult && generating === "listeningScript") {
setEditing(true);
setScriptLocal(genResult[0].script);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
setScriptLocal(genResult.result[0].script);
setIsDialogDropdownOpen(true);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
}, [genResult]);
const renderContent = (editing: boolean) => {
useEffect(() => {
if (genResult && generating === "audio") {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult]);
useEffect(() => {
const scriptRes = levelGenResults.find((res) => res.generating === "listeningScript");
if (levelGenResults && scriptRes) {
setEditing(true);
setScriptLocal(scriptRes.result[0].script);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "listeningScript") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
useEffect(() => {
const scriptRes = levelGenResults.find((res) => res.generating === "audio");
if (levelGenResults && scriptRes) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "audio") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
const renderContent = (editing: boolean, listeningSection?: number) => {
if (scriptLocal === undefined && !editing) {
return (
<Card>
@@ -53,10 +97,10 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
</Card>
);
}
return (
<>
{generating === "audio" ? (<GenLoader module="listening" custom="Generating audio ..." />) : (
<>
{listeningPart.audio?.source && (
<AudioPlayer
key={sectionId}
@@ -64,6 +108,8 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
color="listening"
/>
)}
</>
)}
<Dropdown
className="mt-8 w-full flex items-center justify-between p-4 bg-white hover:bg-gray-50 transition-colors border rounded-xl border-gray-200"
contentWrapperClassName="rounded-xl mt-2"
@@ -71,16 +117,22 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
<div className="flex items-center space-x-3">
<MdHeadphones className={clsx(
"h-5 w-5",
`text-ielts-${currentModule}`
`text-ielts-${module}`
)} />
<span className="font-medium text-gray-900">{(sectionId === 1 || sectionId === 3) ? "Conversation" : "Monologue"}</span>
<span className="font-medium text-gray-900">{
listeningSection === undefined ?
([1, 3].includes(sectionId) ? "Conversation" : "Monologue") :
([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")}
</span>
</div>
}
open={isDialogDropdownOpen}
setIsOpen={setIsDialogDropdownOpen}
>
<ScriptRender
local={scriptLocal}
setLocal={setScriptLocal}
section={sectionId}
section={level ? listeningSection! : sectionId}
editing={editing}
/>
</Dropdown>
@@ -91,14 +143,20 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
return (
<SectionContext
sectionId={sectionId}
title={(sectionId === 1 || sectionId === 3) ? "Conversation" : "Monologue"}
title={
listeningSection === undefined ?
([1, 3].includes(sectionId) ? "Conversation" : "Monologue") :
([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")
}
description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`}
renderContent={renderContent}
editing={editing}
onSave={handleSave}
onEdit={modeHandle}
onEdit={handleEdit}
onDiscard={handleDiscard}
module={currentModule}
module={module}
context="listeningScript"
listeningSection={listeningSection}
/>
);
};

View File

@@ -1,53 +1,81 @@
import { useEffect, useState } from "react";
import { ReadingPart } from "@/interfaces/exam";
import { LevelPart, ReadingPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import Passage from "../../Shared/Passage";
import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor";
import useSectionEdit from "../../Hooks/useSectionEdit";
import { Module } from "@/interfaces";
interface Props {
module: Module;
sectionId: number;
level?: boolean;
}
const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => {
const {currentModule, dispatch } = useExamEditorStore();
const { genResult, state, generating } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
const ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) => {
const { dispatch } = useExamEditorStore();
const sectionState = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const readingPart = state as ReadingPart;
const [title, setTitle] = useState(readingPart.text.title);
const [content, setContent] = useState(readingPart.text.content);
const { genResult, state, levelGenResults, levelGenerating } = sectionState;
const readingPart = state as ReadingPart | LevelPart;
const [title, setTitle] = useState(readingPart.text?.title || '');
const [content, setContent] = useState(readingPart.text?.content || '');
const [passageOpen, setPassageOpen] = useState(false);
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
const { editing, handleSave, handleDiscard, handleEdit, setEditing } = useSectionEdit({
sectionId,
mode: "edit",
onSave: () => {
let newState = {...state} as ReadingPart;
newState.text.title = title;
newState.text.content = content;
dispatch({type: 'UPDATE_SECTION_STATE', payload: {sectionId, update: newState}})
let newState = { ...state } as ReadingPart | LevelPart;
newState.text = {
title, content
}
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
setEditing(false);
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "genResult", value: undefined } })
}
if (levelGenResults.find((res) => res.generating === "passage")) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenResults", value: levelGenResults.filter((res) => res.generating !== "passage") } })
}
},
onDiscard: () => {
setTitle(readingPart.text.title);
setContent(readingPart.text.content);
setTitle(readingPart.text?.title || '');
setContent(readingPart.text?.content || '');
},
onMode: () => {
onEdit: () => {
setPassageOpen(false);
}
});
useEffect(()=> {
if (genResult !== undefined && generating === "context") {
useEffect(() => {
if (genResult && genResult.generating === "passage") {
setEditing(true);
console.log(genResult);
setTitle(genResult[0].title);
setContent(genResult[0].text);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "genResult", value: undefined}})
setTitle(genResult.result[0].title);
setContent(genResult.result[0].text);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
}, [genResult]);
useEffect(() => {
const passageRes = levelGenResults.find((res) => res.generating === "passage");
if (levelGenResults && passageRes) {
setEditing(true);
setTitle(passageRes.result[0].title);
setContent(passageRes.result[0].text);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "passage") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
const renderContent = (editing: boolean) => {
@@ -98,9 +126,10 @@ const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => {
renderContent={renderContent}
editing={editing}
onSave={handleSave}
onEdit={modeHandle}
module={currentModule}
onEdit={handleEdit}
module={module}
onDiscard={handleDiscard}
context="passage"
/>
);
};

View File

@@ -0,0 +1,92 @@
import { Exercise } from "@/interfaces/exam";
import ExerciseItem, { isExerciseItem } from "./types";
import MultipleChoice from "../../Exercises/MultipleChoice";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import writeBlanks from "./writeBlanks";
import TrueFalse from "../../Exercises/TrueFalse";
import fillBlanks from "./fillBlanks";
import MatchSentences from "../../Exercises/MatchSentences";
import Writing from "../../Exercises/Writing";
const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
const items: ExerciseItem[] = exercises.map((exercise, index) => {
let firstQuestionId, lastQuestionId;
switch (exercise.type) {
case "multipleChoice":
firstQuestionId = exercise.questions[0].id;
lastQuestionId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Multiple Choice Questions'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
};
case "trueFalse":
firstQuestionId = exercise.questions[0].id
lastQuestionId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='True/False/Not Given'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <TrueFalse exercise={exercise} sectionId={sectionId} />
};
case "matchSentences":
firstQuestionId = exercise.sentences[0].id;
lastQuestionId = exercise.sentences[exercise.sentences.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <MatchSentences exercise={exercise} sectionId={sectionId} />
};
case "fillBlanks":
return fillBlanks(exercise, index, sectionId);
case "writeBlanks":
return writeBlanks(exercise, index, sectionId);
case "writing":
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`Writing Task: ${exercise.variant === "letter" ? "Letter" : "Essay"}`}
firstId={exercise.sectionId!.toString()}
lastId={exercise.sectionId!.toString()}
prompt={exercise.prompt}
/>
),
content: <Writing key={exercise.id} exercise={exercise} sectionId={sectionId} index={index} module="level" />
};
default:
return {} as unknown as ExerciseItem;
}
}).filter(isExerciseItem);
/*return mappedItems.filter((item): item is ExerciseItem =>
item !== null && isExerciseItem(item)
);*/
return items;
};
export default getExerciseItems;

View File

@@ -0,0 +1,78 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import ExerciseItem from "./types";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import FillBlanksLetters from "../../Exercises/Blanks/Letters";
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
interface LetterWord {
letter: string;
word: string;
}
function isLetterWordArray(words: (string | LetterWord | FillBlanksMCOption)[]): words is LetterWord[] {
return words.length > 0 &&
words.every(item =>
typeof item === 'object' &&
'letter' in item &&
'word' in item &&
!('options' in item)
);
}
function isFillBlanksMCOptionArray(words: (string | LetterWord | FillBlanksMCOption)[]): words is FillBlanksMCOption[] {
return words.length > 0 &&
words.every(item =>
typeof item === 'object' &&
'id' in item &&
'options' in item &&
typeof (item as FillBlanksMCOption).options === 'object' &&
'A' in (item as FillBlanksMCOption).options &&
'B' in (item as FillBlanksMCOption).options &&
'C' in (item as FillBlanksMCOption).options &&
'D' in (item as FillBlanksMCOption).options
);
}
const fillBlanks = (exercise: FillBlanksExercise, index: number, sectionId: number): ExerciseItem => {
const firstWordId = exercise.solutions[0].id;
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
if (isLetterWordArray(exercise.words)) {
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Fill Blanks Question'
firstId={firstWordId}
lastId={lastWordId}
prompt={exercise.prompt}
/>
),
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
};
}
if (isFillBlanksMCOptionArray(exercise.words)) {
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Fill Blanks: MC Question'
firstId={firstWordId}
lastId={lastWordId}
prompt={exercise.prompt}
/>
),
content: <FillBlanksMC exercise={exercise} sectionId={sectionId} />
};
}
// Don't know where the fillBlanks with words as string fits
throw new Error(`Unsupported Exercise`);
}
export default fillBlanks;

View File

@@ -1,8 +1,7 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import SortableSection from "../../Shared/SortableSection";
import getReadingQuestions from '../SectionExercises/reading';
import { Exercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import ExerciseItem, { ReadingExercise } from "./types";
import ExerciseItem from "./types";
import Dropdown from "@/components/Dropdown";
import useExamEditorStore from "@/stores/examEditor";
import Writing from "../../Exercises/Writing";
@@ -13,15 +12,14 @@ import {
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
closestCenter,
UniqueIdentifier,
} from '@dnd-kit/core';
import GenLoader from "../../Exercises/Shared/GenLoader";
import { ExamPart } from "@/stores/examEditor/types";
import getListeningItems from "./listening";
import getLevelQuestionItems from "./level";
import { ExamPart, Generating } from "@/stores/examEditor/types";
import React from "react";
import getExerciseItems from "./exercises";
import { Action } from "@/stores/examEditor/reducers";
import { writingTask } from "@/stores/examEditor/sections";
interface QuestionItemsResult {
@@ -30,31 +28,135 @@ interface QuestionItemsResult {
}
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const { sections, expandedSections } = useExamEditorStore(
(state) => state.modules[currentModule]
);
const { genResult, generating, state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
const dispatch = useExamEditorStore(state => state.dispatch);
const currentModule = useExamEditorStore(state => state.currentModule);
const sections = useExamEditorStore(state => state.modules[currentModule].sections);
const expandedSections = useExamEditorStore(state => state.modules[currentModule].expandedSections);
const section = useExamEditorStore(
state => state.modules[currentModule].sections.find(
section => section.sectionId === sectionId
)
);
const genResult = section?.genResult;
const generating = section?.generating;
const levelGenResults = section?.levelGenResults
const levelGenerating = section?.levelGenerating;
const sectionState = section?.state;
useEffect(() => {
if (genResult !== undefined && generating === "exercises") {
const newExercises = genResult[0].exercises;
if (genResult && genResult.generating === "exercises" && genResult.module === currentModule) {
const newExercises = genResult.result[0].exercises;
dispatch({
type: "UPDATE_SECTION_STATE", payload: {
sectionId, update: {
exercises: [...(state as ExamPart).exercises, ...newExercises]
sectionId,
module: genResult.module,
update: {
exercises: [...(sectionState as ExamPart).exercises, ...newExercises]
}
}
})
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } })
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating.startsWith("exercises"))) {
const newExercises = levelGenResults
.filter(res => res.generating.startsWith("exercises"))
.map(res => res.result[0].exercises)
.flat();
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises, ...newExercises]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !g?.startsWith("exercises"))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !res.generating.startsWith("exercises"))
}
}
] as Action[];
updates.forEach(update => dispatch(update));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults.some(res => res.generating === "writing_letter" || res.generating === "writing_2")) {
const results = levelGenResults.filter(res => res.generating === "writing_letter" || res.generating === "writing_2");
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [...(sectionState as ExamPart).exercises,
...results.map((res)=> {
return {
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
} as WritingExercise;
})
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g => !results.flatMap(res => res.generating as Generating).includes(g))
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res => !results.flatMap(res => res.generating as Generating).includes(res.generating))
}
}
] as Action[];
updates.forEach(update => dispatch(update));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
const currentSection = sections.find((s) => s.sectionId === sectionId)!;
const sensors = useSensors(
@@ -62,42 +164,12 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
);
const questionItems = (): QuestionItemsResult => {
let result: QuestionItemsResult = {
ids: [],
items: []
};
switch (currentModule) {
case "reading": {
const items = getReadingQuestions(
(currentSection.state as ReadingPart).exercises as ReadingExercise[],
sectionId
);
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
result.ids = result.items.map(item => item.id);
break;
const part = currentSection.state as ReadingPart | ListeningPart | LevelPart;
const items = getExerciseItems(part.exercises, sectionId);
return {
items,
ids: items.map(item => item.id)
}
case "listening": {
const items = getListeningItems(
(currentSection.state as ListeningPart).exercises as Exercise[],
sectionId
);
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
result.ids = result.items.map(item => item.id);
break;
}
case "level": {
const items = getLevelQuestionItems(
(currentSection.state as LevelPart).exercises as Exercise[],
sectionId
);
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
result.ids = result.items.map(item => item.id);
break;
}
}
return result;
};
const background = (component: ReactNode) => {
@@ -108,11 +180,13 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
);
}
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} />);
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} module="writing" />);
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} />);
const questions = questionItems();
// #############################################################################
// Typescript checks so that the compiler and builder don't freak out
const filteredIds = (questions.ids ?? []).filter(Boolean);
function isValidItem(item: ExerciseItem | undefined): item is ExerciseItem {
@@ -122,19 +196,17 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
React.isValidElement(item.label) &&
React.isValidElement(item.content);
}
const filteredItems = (questions.items ?? []).filter(isValidItem);
// #############################################################################
console.log(levelGenerating);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId } })}
onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId, module: currentModule } })}
>
{(currentModule === "level" && questions.ids?.length === 0 && generating === undefined) ? (
background(<span className="flex justify-center">Generated exercises will appear here!</span>)
) : (
expandedSections.includes(sectionId) &&
{expandedSections.includes(sectionId) &&
questions.items &&
questions.items.length > 0 &&
questions.ids &&
@@ -160,9 +232,16 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
</SortableContext>
</div>
)
)}
}
{generating === "exercises" && <GenLoader module={currentModule} className="mt-4" />}
{currentModule === "level" && (
<>
{
questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing")) && generating !== "exercises"
&& background(<span className="flex justify-center">Generated exercises will appear here!</span>)}
{levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing")) && <GenLoader module={currentModule} className="mt-4" />}
</>)
}
</DndContext >
);
}

View File

@@ -1,75 +0,0 @@
import { Exercise } from "@/interfaces/exam";
import ExerciseItem, { isExerciseItem } from "./types";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import MultipleChoice from "../../Exercises/MultipleChoice";
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
import Passage from "../../Shared/Passage";
const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
const previewLabel = (text: string) => {
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : "";
}
const items: ExerciseItem[] = exercises.map((exercise, index) => {
let firstWordId, lastWordId;
switch (exercise.type) {
case "multipleChoice":
let content = <MultipleChoice exercise={exercise} sectionId={sectionId} />;
const isReadingPassage = exercise.mcVariant && exercise.mcVariant === "passageUtas";
if (isReadingPassage) {
content = (<>
<div className="p-4">
<Passage
title={exercise.passage?.title!}
content={exercise.passage?.content!}
/>
</div>
<MultipleChoice exercise={exercise} sectionId={sectionId} /></>
);
}
firstWordId = exercise.questions[0].id;
lastWordId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={isReadingPassage ? `Reading Passage: MC Questions #${firstWordId} - #${lastWordId}` : `Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content
};
case "fillBlanks":
firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Fill Blanks: MC Question #${firstWordId} - #${lastWordId}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <FillBlanksMC exercise={exercise} sectionId={sectionId} />
};
default:
return {} as unknown as ExerciseItem;
}
}).filter(isExerciseItem);
return items;
};
export default getLevelQuestionItems;

View File

@@ -1,127 +0,0 @@
import ExerciseItem, { isExerciseItem } from './types';
import ExerciseLabel from '../../Shared/ExerciseLabel';
import FillBlanksLetters from '../../Exercises/Blanks/Letters';
import { Exercise, WriteBlanksExercise } from '@/interfaces/exam';
import MultipleChoice from '../../Exercises/MultipleChoice';
import WriteBlanksForm from '../../Exercises/WriteBlanksForm';
import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill';
import WriteBlanks from '../../Exercises/WriteBlanks';
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string): ExerciseItem => {
const firstWordId = exercise.solutions[0].id;
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
switch (exercise.variant) {
case 'form':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Write Blanks: Form #${firstWordId} - #${lastWordId}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <WriteBlanksForm exercise={exercise} sectionId={sectionId} />
};
case 'fill':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Write Blanks: Fill #${firstWordId} - #${lastWordId}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
};
case 'questions':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Write Blanks: Questions #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <WriteBlanks exercise={exercise} sectionId={sectionId} title='Write Blanks: Questions' />
};
}
throw new Error(`Just so that typescript doesnt complain`);
};
const getListeningItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
const previewLabel = (text: string) => {
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : "";
};
const mappedItems = exercises.map((exercise, index): ExerciseItem | null => {
let firstWordId, lastWordId;
switch (exercise.type) {
case "fillBlanks":
firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Fill Blanks Question #${firstWordId} - #${lastWordId}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
};
case "writeBlanks":
return writeBlanks(exercise, index, sectionId, previewLabel);
case "multipleChoice":
firstWordId = exercise.questions[0].id;
lastWordId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
};
default:
return null;
}
});
return mappedItems.filter((item): item is ExerciseItem =>
item !== null && isExerciseItem(item)
);
};
export default getListeningItems;

View File

@@ -1,117 +0,0 @@
import ExerciseItem, { isExerciseItem, ReadingExercise } from './types';
import WriteBlanks from "@/editor/Exercises/WriteBlanks";
import ExerciseLabel from '../../Shared/ExerciseLabel';
import MatchSentences from '../../Exercises/MatchSentences';
import TrueFalse from '../../Exercises/TrueFalse';
import FillBlanksLetters from '../../Exercises/Blanks/Letters';
import MultipleChoice from '../../Exercises/MultipleChoice';
const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): ExerciseItem[] => {
const previewLabel = (text: string) => {
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""
}
const items: ExerciseItem[] = exercises.map((exercise, index) => {
let firstWordId, lastWordId;
switch (exercise.type) {
case "fillBlanks":
firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Fill Blanks Question #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
};
case "writeBlanks":
firstWordId = exercise.solutions[0].id;
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Write Blanks Question #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <WriteBlanks exercise={exercise} sectionId={sectionId} />
};
case "matchSentences":
firstWordId = exercise.sentences[0].id;
lastWordId = exercise.sentences[exercise.sentences.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`${exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"} #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <MatchSentences exercise={exercise} sectionId={sectionId}/>
};
case "trueFalse":
firstWordId = exercise.questions[0].id
lastWordId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`True/False/Not Given #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <TrueFalse exercise={exercise} sectionId={sectionId} />
};
case "multipleChoice":
firstWordId = exercise.questions[0].id;
lastWordId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
label={`Multiple Choice Questions #${firstWordId} - #${lastWordId}`}
preview={
<>
&quot;{previewLabel(exercise.prompt)}...&quot;
</>
}
/>
),
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
};
}
}).filter(isExerciseItem);
return items;
};
export default getExerciseItems;

View File

@@ -1,5 +1,3 @@
import { FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
export default interface ExerciseItem {
id: string;
sectionId: number;
@@ -7,8 +5,6 @@ export default interface ExerciseItem {
content: React.ReactNode;
}
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise | MultipleChoiceExercise;
export function isExerciseItem(item: unknown): item is ExerciseItem {
return item !== undefined &&
item !== null &&

View File

@@ -0,0 +1,58 @@
import { WriteBlanksExercise } from "@/interfaces/exam";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import WriteBlanksForm from "../../Exercises/WriteBlanksForm";
import WriteBlanksFill from "../../Exercises/Blanks/WriteBlankFill";
import WriteBlanks from "../../Exercises/WriteBlanks";
import ExerciseItem from "./types";
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number): ExerciseItem => {
const firstQuestionId = exercise.solutions[0].id;
const lastQuestionId = exercise.solutions[exercise.solutions.length - 1].id;
switch (exercise.variant) {
case 'form':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Form'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanksForm exercise={exercise} sectionId={sectionId} />
};
case 'fill':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Fill'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
};
default:
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Questions'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanks exercise={exercise} sectionId={sectionId} />
};
}
};
export default writeBlanks;

View File

@@ -7,6 +7,8 @@ import useExamEditorStore from '@/stores/examEditor';
import { ModuleState } from '@/stores/examEditor/types';
import ListeningContext from './SectionContext/listening';
import SectionDropdown from '../Shared/SectionDropdown';
import LevelContext from './SectionContext/level';
import { Module } from '@/interfaces';
const SectionRenderer: React.FC = () => {
@@ -39,9 +41,10 @@ const SectionRenderer: React.FC = () => {
}
};
const ContextMap: Record<string, React.ComponentType<{ sectionId: number; }>> = {
const ContextMap: Record<string, React.ComponentType<{ sectionId: number; module: Module }>> = {
reading: ReadingContext,
listening: ListeningContext,
level: LevelContext,
};
const SectionContext = ContextMap[currentModule];
@@ -83,7 +86,7 @@ const SectionRenderer: React.FC = () => {
onFocus={() => updateModule({ focusedSection: id })}
tabIndex={id + 1}
>
{currentModule in ContextMap && <SectionContext sectionId={id} />}
{currentModule in ContextMap && <SectionContext sectionId={id} module={currentModule} />}
<SectionExercises sectionId={id} />
</div>
)}

View File

@@ -1,5 +1,5 @@
import { Module } from "@/interfaces";
import { GeneratedExercises, GeneratorState } from "../Shared/ExercisePicker/generatedExercises";
import { GeneratedExercises, GeneratorState } from "../ExercisePicker/generatedExercises";
import { SectionState } from "@/stores/examEditor/types";

View File

@@ -14,27 +14,51 @@ interface GeneratorConfig {
export function generate(
sectionId: number,
module: Module,
type: "context" | "exercises",
type: Generating,
config: GeneratorConfig,
mapData: (data: any) => Record<string, any>[]
mapData: (data: any) => Record<string, any>[],
levelSectionId?: number,
level: boolean = false
) {
const dispatch = useExamEditorStore.getState().dispatch;
const setGenerating = (sectionId: number, generating: Generating, level: boolean, remove?: boolean) => {
const state = useExamEditorStore.getState();
const dispatch = state.dispatch;
let generatingUpdate;
if (level) {
if (remove) {
generatingUpdate = state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating.filter(g => g === generating)
}
else {
generatingUpdate = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating, generating];
}
} else {
generatingUpdate = generating;
}
const setGenerating = (sectionId: number, generating: Generating) => {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { sectionId, module, field: "generating", value: generating }
payload: { sectionId : sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate }
});
};
const setGeneratedExercises = (sectionId: number, exercises: Record<string, any>[] | undefined) => {
const setGeneratedResult = (sectionId: number, generating: Generating, result: Record<string, any>[] | undefined, level: boolean) => {
const state = useExamEditorStore.getState();
const dispatch = state.dispatch;
let genResults;
if (level) {
genResults = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenResults, { generating, result, module }];
} else {
genResults = { generating, result, module };
}
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { sectionId, module, field: "genResult", value: exercises }
payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults }
});
};
setGenerating(sectionId, type);
setGenerating(level ? levelSectionId! : sectionId, type, level);
const queryString = config.queryParams
? new URLSearchParams(config.queryParams).toString()
@@ -49,13 +73,11 @@ export function generate(
request
.then((result) => {
playSound("check");
setGeneratedExercises(sectionId, mapData(result.data));
setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level);
})
.catch((error) => {
setGenerating(sectionId, undefined, level, true);
playSound("error");
toast.error("Something went wrong! Try to generate again.");
})
.finally(() => {
setGenerating(sectionId, undefined);
});
}

View File

@@ -2,6 +2,7 @@ import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor";
import { Generating } from "@/stores/examEditor/types";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { BsArrowRepeat } from "react-icons/bs";
import { GiBrain } from "react-icons/gi";
@@ -11,25 +12,37 @@ interface Props {
genType: Generating;
generateFnc: (sectionId: number) => void
className?: string;
levelId?: number;
level?: boolean;
}
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => {
const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId));
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, levelId }) => {
const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == levelId ? levelId : sectionId));
const [loading, setLoading] = useState(false);
const generating = section?.generating;
const levelGenerating = section?.levelGenerating;
useEffect(()=> {
const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType);
if (loading !== gen) {
setLoading(gen);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, levelGenerating])
if (section === undefined) return <></>;
const {generating} = section;
const loading = generating && generating === genType;
return (
<button
key={`section-${sectionId}`}
className={clsx(
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module}`,
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`,
className
)}
onClick={loading ? () => { } : () => generateFnc(sectionId)}
disabled={loading}
onClick={loading ? () => { } : () => generateFnc(levelId ? levelId : sectionId)}
>
{loading ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center">

View File

@@ -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<LevelSectionSettings>, schedule?: boolean) => void;
}
const SectionPicker: React.FC<Props> = ({
module,
sectionId,
localSettings,
updateLocalAndScheduleGlobal
}) => {
const { dispatch } = useExamEditorStore();
const [selectedValue, setSelectedValue] = React.useState<number | null>(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 (
<Dropdown
title={getTitle()}
module={module}
open={localSettings[openPicker]}
setIsOpen={(isOpen: boolean) =>
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}`}
>
<div className="space-y-2 pt-3 pb-3 px-2 border border-gray-200 rounded-lg shadow-inner">
{options.map((num) => (
<label
key={num}
className={`
flex items-center space-x-3 font-semibold cursor-pointer p-2 rounded
transition-colors duration-200
${currentValue === num
? `bg-ielts-${module}/90 text-white`
: `hover:bg-ielts-${module}/70 text-gray-700`}
`}
>
<input
type="radio"
name={`${module === "reading" ? 'passage' : 'section'}-${sectionId}`}
value={num}
checked={currentValue === num}
onChange={() => handleSectionChange(num)}
className={`
h-5 w-5 cursor-pointer
accent-ielts-${module}
`}
/>
<div className="flex items-center space-x-2">
<span>
{module === "reading" ? `Passage ${num}` : `Section ${num}`}
</span>
</div>
</label>
))}
</div>
</Dropdown>
);
};
export default SectionPicker;

View File

@@ -10,9 +10,10 @@ interface Props {
setIsOpen: (isOpen: boolean) => void;
children: ReactNode;
center?: boolean;
contentWrapperClassName?: string;
}
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, disabled = false, center = false}) => {
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, contentWrapperClassName = '', disabled = false, center = false}) => {
return (
<Dropdown
title={title}
@@ -21,7 +22,7 @@ const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, chi
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
open ? "rounded-t-lg" : "rounded-lg"
)}
contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${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}

View File

@@ -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 = () => {
@@ -27,7 +33,7 @@ const LevelSettings: React.FC = () => {
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<SectionSettings>(
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
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 (
<SettingsEditor
sectionLabel={`Part ${focusedSection}`}
@@ -117,17 +128,17 @@ const LevelSettings: React.FC = () => {
submitModule={submitLevel}
>
<div>
<Dropdown title="Add Exercises" className={
<Dropdown title="Add Level Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-level/70 border-ielts-level hover:bg-ielts-level",
"text-white shadow-md transition-all duration-300",
localSettings.isExerciseDropdownOpen ? "rounded-t-lg" : "rounded-lg"
localSettings.isLevelDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
open={localSettings.isLevelDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isLevelDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="level"
@@ -136,7 +147,130 @@ const LevelSettings: React.FC = () => {
/>
</Dropdown>
</div>
</SettingsEditor>
<div>
<Dropdown title="Add Reading Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-reading/70 border-ielts-reading hover:bg-ielts-reading",
"text-white shadow-md transition-all duration-300",
localSettings.isReadingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isReadingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "reading", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, generatePassageDisabled: readingSection === undefined, levelId: readingSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Listening Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-listening/70 border-ielts-listening hover:bg-ielts-listening",
"text-white shadow-md transition-all duration-300",
localSettings.isListeningDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isListeningDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "listening", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ListeningComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, audioContextDisabled: listeningSection === undefined, levelId: listeningSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Writing Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-writing/70 border-ielts-writing hover:bg-ielts-writing",
"text-white shadow-md transition-all duration-300",
localSettings.isWritingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isWritingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="writing"
sectionId={focusedSection}
difficulty={difficulty}
levelSectionId={focusedSection}
level
/>
</Dropdown>
</div >
{/*
<div>
<Dropdown title="Add Speaking Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
>
<Dropdown title="Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<ExercisePicker
module="speaking"
sectionId={focusedSection}
difficulty={difficulty}
levelSectionId={focusedSection}
level
/>
</div>
</Dropdown>
{speakingExercise !== undefined &&
<Dropdown title="Configure Speaking Exercise" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
>
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise }}
level
/>
</Dropdown>
}
</Dropdown>
</div>
*/}
</SettingsEditor >
);
};

View File

@@ -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<ListeningSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ListeningPart | LevelPart;
audioContextDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ListeningComponents: React.FC<Props> = ({ 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 (
<>
<Dropdown
title="Audio Context"
module="listening"
open={localSettings.isAudioContextOpen}
disabled={audioContextDisabled}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.listeningTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="listening"
genType="listeningScript"
sectionId={focusedSection}
generateFnc={generateScript}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="listening"
open={localSettings.isListeningTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningTopicOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<ExercisePicker
module="listening"
sectionId={levelId !== undefined ? levelId : focusedSection}
difficulty={difficulty}
extraArgs={{ script: currentSection === undefined || currentSection.audio === undefined ? "" : currentSection.script }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
<Dropdown
title="Generate Audio"
module="listening"
open={localSettings.isAudioGenerationOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4">
<span className="bg-gray-100 px-3.5 py-2.5 rounded-l-lg border border-r-0 border-gray-300">
Generate audio recording for this section
</span>
<div className="-ml-2.5">
<GenerateBtn
module="listening"
genType="audio"
sectionId={levelId ? levelId : focusedSection}
generateFnc={generateAudio}
levelId={focusedSection}
/>
</div>
</div>
</Dropdown>
</>
);
};
export default ListeningComponents;

View File

@@ -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,
@@ -62,30 +63,6 @@ const ListeningSettings: React.FC = () => {
}
];
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}
>
<Dropdown
title="Audio Context"
module={currentModule}
open={localSettings.isAudioContextOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.topic}
<ListeningComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module={currentModule}
genType="context"
sectionId={focusedSection}
generateFnc={generateScript}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module={currentModule}
open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
>
<ExercisePicker
module="listening"
sectionId={focusedSection}
difficulty={difficulty}
/>
</Dropdown>
<Dropdown
title="Generate Audio"
module={currentModule}
open={localSettings.isAudioGenerationOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
center
>
<GenerateBtn
module={currentModule}
genType="media"
sectionId={focusedSection}
generateFnc={generateAudio}
className="mb-4"
/>
</Dropdown>
</SettingsEditor>
);
};

View File

@@ -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<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ReadingComponents: React.FC<Props> = ({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 (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={generatePassageDisabled}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
difficulty={difficulty}
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
);
};
export default ReadingComponents;

View File

@@ -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}
>
<Dropdown
title="Generate Passage"
module={currentModule}
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.topic}
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module={currentModule}
genType="context"
sectionId={focusedSection}
generateFnc={generatePassage}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module={currentModule}
open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen })}
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
>
<ExercisePicker
module="reading"
sectionId={focusedSection}
difficulty={difficulty}
extraArgs={{ text: currentSection === undefined ? "" : currentSection.text.content }}
/>
</Dropdown>
</SettingsEditor>
);
};

View File

@@ -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<SpeakingSectionSettings>(
currentModule,
focusedSection,
);
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(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<string, string>();
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 (
<SettingsEditor
sectionLabel={`Speaking ${focusedSection}`}
sectionId={focusedSection}
module="speaking"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitSpeaking}
>
<Dropdown
title="Generate Script"
module={currentModule}
open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
>
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center")}>
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.topic}
/>
</div>
{focusedSection === 1 &&
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onSecondTopicChange}
roundness="full"
value={localSettings.secondTopic}
/>
</div>
}
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
<GenerateBtn
module={currentModule}
genType="context"
sectionId={focusedSection}
generateFnc={generateScript}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Generate Video"
module={currentModule}
open={localSettings.isGenerateAudioOpen}
disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)}
>
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs">
<select
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
onChange={(e) => {
if (e.target.value === "") {
setSelectedAvatar(null);
} else {
const [name, gender] = e.target.value.split("-");
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
if (avatar) setSelectedAvatar(avatar);
}
}}
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">Select an avatar</option>
{speakingAvatars.map((avatar) => (
<option
key={`${avatar.name}-${avatar.gender}`}
value={`${avatar.name}-${avatar.gender}`}
>
{avatar.name}
</option>
))}
</select>
<div className="absolute right-2.5 top-2.5 pointer-events-none">
{selectedAvatar && (
selectedAvatar.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)
)}
</div>
</div>
<GenerateBtn
module={currentModule}
genType="media"
sectionId={focusedSection}
generateFnc={generateVideoCallback}
/>
</div>
</Dropdown>
</SettingsEditor>
);
};
export default SpeakingSettings;

View File

@@ -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<SpeakingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart;
level?: boolean;
module?: Module;
}
const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, section, level, module = "speaking" }) => {
const { currentModule, speakingAvatars, dispatch } = useExamEditorStore();
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(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 (
<>
<Dropdown
title="Generate Script"
module="speaking"
open={localSettings.isSpeakingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
>
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center")}>
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.speakingTopic}
/>
</div>
{focusedSection === 1 &&
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onSecondTopicChange}
roundness="full"
value={localSettings.speakingSecondTopic}
/>
</div>
}
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
<GenerateBtn
module="speaking"
genType="speakingScript"
sectionId={focusedSection}
generateFnc={generateScript}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Generate Video"
module="speaking"
open={localSettings.isGenerateVideoOpen}
disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)}
>
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs">
<select
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
onChange={(e) => {
if (e.target.value === "") {
setSelectedAvatar(null);
} else {
const [name, gender] = e.target.value.split("-");
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
if (avatar) setSelectedAvatar(avatar);
}
}}
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">Select an avatar (Optional)</option>
{speakingAvatars.map((avatar) => (
<option
key={`${avatar.name}-${avatar.gender}`}
value={`${avatar.name}-${avatar.gender}`}
>
{avatar.name}
</option>
))}
</select>
<div className="absolute right-2.5 top-2.5 pointer-events-none">
{selectedAvatar && (
selectedAvatar.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)
)}
</div>
</div>
<GenerateBtn
module="speaking"
genType="video"
sectionId={focusedSection}
generateFnc={generateVideoCallback}
/>
</div>
</Dropdown>
</>
);
};
export default SpeakingComponents;

View File

@@ -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<SpeakingSectionSettings>(
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<string, string>();
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 (
<SettingsEditor
sectionLabel={`Speaking ${focusedSection}`}
sectionId={focusedSection}
module="speaking"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitSpeaking}
>
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: currentSection }}
/>
</SettingsEditor>
);
};
export default SpeakingSettings;

View File

@@ -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<WritingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection?: WritingExercise;
level?: boolean;
}
const WritingComponents: React.FC<Props> = ({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 (
<>
<Dropdown
title="Generate Instructions"
module={"writing"}
open={localSettings.isWritingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-writing`: ''}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.writingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
genType="writing"
module={"writing"}
sectionId={focusedSection}
generateFnc={generatePassage}
/>
</div>
</div>
</Dropdown>
</>
);
};
export default WritingComponents;

View File

@@ -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<SectionSettings>(
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<WritingSectionSettings>(
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}
>
<Dropdown
title="Generate Instructions"
module={currentModule}
open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.topic}
<WritingComponents
{...{ localSettings, updateLocalAndScheduleGlobal }}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
genType="context"
module={currentModule}
sectionId={focusedSection}
generateFnc={generatePassage}
/>
</div>
</div>
</Dropdown>
</SettingsEditor>
);
};

View File

@@ -1,13 +1,26 @@
interface Props {
label: string;
preview?: React.ReactNode;
type: string;
firstId: string;
lastId: string;
prompt: string;
}
const ExerciseLabel: React.FC<Props> = ({label, preview}) => {
const previewLabel = (text: string) => {
return <>
&quot;{text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""}...&quot;
</>
}
const label = (type: string, firstId: string, lastId: string) => {
return `${type} #${firstId} ${firstId === lastId ? '' : `- #${lastId}`}`;
}
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
return (
<div className="flex w-full justify-between items-center mr-4">
<span className="font-semibold">{label}</span>
{preview && <div className="text-sm font-light italic">{preview}</div>}
<span className="font-semibold">{label(type, firstId, lastId)}</span>
<div className="text-sm font-light italic">{previewLabel(prompt)}</div>
</div>
);
}

View File

@@ -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<Props> = ({ title, description, editing, isEvaluationEnabled, handleSave, handleDiscard, modeHandle, evaluationHandle, children, mode = "delete", module }) => {
const Header: React.FC<Props> = ({
title, description, editing, isEvaluationEnabled, handleSave, handleDiscard, handleDelete, handleEdit, handlePractice, children, module }) => {
return (
<div className="flex justify-between items-center mb-6 text-sm">
<div>
@@ -48,26 +50,18 @@ const Header: React.FC<Props> = ({ title, description, editing, isEvaluationEnab
<MdRefresh size={18} />
Discard
</button>
{mode === "delete" ? (
{handleEdit && (
<button
onClick={modeHandle}
className="px-4 py-2 bg-white border border-red-500 text-red-500 hover:bg-red-50 rounded-lg transition-all duration-200 flex items-center gap-2"
>
<MdDelete size={18} />
Delete
</button>
) : (
<button
onClick={modeHandle}
onClick={handleEdit}
className={`px-4 py-2 bg-ielts-${module}/80 text-white hover:bg-ielts-${module} rounded-lg transition-all duration-200 flex items-center gap-2`}
>
{editing ? <MdEditOff size={18} /> : <MdEdit size={18} />}
Edit
</button>
)}
{mode === "delete" &&
{handlePractice &&
<button
onClick={evaluationHandle}
onClick={handlePractice}
className={clsx(
"px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
isEvaluationEnabled
@@ -75,10 +69,19 @@ const Header: React.FC<Props> = ({ title, description, editing, isEvaluationEnab
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
)}
>
{isEvaluationEnabled ? <MdGrade size={18} /> : <MdOutlineGrade size={18} />}
{isEvaluationEnabled ? 'Graded Exercise' : 'Practice Only'}
{isEvaluationEnabled ? <HiOutlineClipboardCheck size={18} /> : <HiOutlineClipboardList size={18} />}
{isEvaluationEnabled ? 'Graded' : 'Practice'}
</button>
}
{handleDelete && (
<button
onClick={handleDelete}
className="px-4 py-2 bg-white border border-red-500 text-red-500 hover:bg-red-50 rounded-lg transition-all duration-200 flex items-center gap-2"
>
<MdDelete size={18} />
Delete
</button>
)}
</div>
</div>
);

View File

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

View File

@@ -322,7 +322,7 @@ export default function Listening({ exam, showSolutions = false, preview = false
minTimer={exam.minTimer}
module="listening"
totalExercises={exam.parts.length}
disableTimer={showSolutions}
disableTimer={showSolutions || preview}
indexLabel="Part"
/>

View File

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

View File

@@ -45,12 +45,14 @@ export interface LevelExam extends ExamBase {
}
export interface LevelPart extends Section {
// to support old exams that have reading passage mc on context
context?: string;
exercises: Exercise[];
audio?: {
source: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
};
script?: Script;
text?: {
title: string;
content: string;
@@ -163,6 +165,7 @@ export interface WritingExercise extends Section {
evaluation?: WritingEvaluation;
}[];
topic?: string;
variant?: string;
isPractice?: boolean
}

View File

@@ -32,8 +32,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const queryParams = queryToURLSearchParams(req);
let endpoint = queryParams.getAll('module').join("/");
if (endpoint.startsWith("level")) {
endpoint = "level/"
} else if (endpoint.startsWith("listening")) {
endpoint = "listening/"
} else if (endpoint.startsWith("reading")) {
endpoint = "reading/"
}
const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`,

View File

@@ -1,5 +1,5 @@
import { Module } from "@/interfaces"
import { Difficulty} from "@/interfaces/exam"
import { Difficulty } from "@/interfaces/exam"
import { sample } from "lodash"
import { ExamPart, ModuleState, SectionState } from "./types"
import { levelPart, listeningSection, readingPart, speakingTask, writingTask } from "@/stores/examEditor/sections"
@@ -18,21 +18,59 @@ const defaultSettings = (module: Module) => {
}
switch (module) {
case 'writing':
return {
...baseSettings,
writingTopic: '',
isWritingTopicOpen: false
}
case 'reading':
return {
...baseSettings,
isPassageOpen: false,
readingTopic: '',
isReadingTopicOpean: false,
}
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" }];
}
}
@@ -99,8 +137,8 @@ export const defaultSectionSettings = (module: Module, sectionId: number, part?:
generating: undefined,
genResult: undefined,
expandedSubSections: [],
exercisePickerState: [],
selectedExercises: [],
levelGenerating: [],
levelGenResults: []
}
}

View File

@@ -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<SectionSettings | ReadingSectionSettings>; } }
| { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; update: Partial<Section>; } }
| { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, sectionId: number; } };
| { type: 'UPDATE_SECTION_SETTINGS'; payload: { sectionId: number; module: Module; update: Partial<SectionSettings | ReadingSectionSettings>; } }
| { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; module: Module; update: Partial<Section>; } }
| { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, module: Module; sectionId: number; } };
export const SECTION_ACTIONS = [
'UPDATE_SECTION_SETTINGS',
@@ -20,24 +20,15 @@ export const sectionReducer = (
state: ExamEditorStore,
action: SectionActions
): Partial<ExamEditorStore> => {
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;
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 +39,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 +61,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 +102,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

View File

@@ -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,6 +167,10 @@ 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:
return exercise;
}
@@ -184,7 +187,6 @@ const reorderModule = (moduleState: ModuleState) => {
let currentId = 1;
let reorderedSections = moduleState.sections.map(section => {
let currentSection = section.state as ReadingPart | ListeningPart | LevelPart;
console.log(currentSection.exercises);
let result = reorderSection(currentSection.exercises, currentId);
currentId = result.lastId;
return {

View File

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

View File

@@ -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<string, string>[];
@@ -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<string, any>[] | undefined;
exercisePickerState: ExerciseConfig[];
selectedExercises: string[];
genResult: {generating: string, result: Record<string, any>[], module: Module} | undefined;
levelGenerating: Generating[];
levelGenResults: {generating: string, result: Record<string, any>[], module: Module}[];
focusedExercise?: number;
writingSection?: number;
speakingSection?: number;
readingSection?: number;
listeningSection?: number;
}
export interface ModuleState {