Merged in feature/ExamGenRework (pull request #109)
Feature/ExamGenRework Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -7,13 +7,16 @@ import { GiBrain } from 'react-icons/gi';
|
|||||||
import { IoTextOutline } from 'react-icons/io5';
|
import { IoTextOutline } from 'react-icons/io5';
|
||||||
import { Switch } from '@headlessui/react';
|
import { Switch } from '@headlessui/react';
|
||||||
import useExamEditorStore from '@/stores/examEditor';
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
import { Module } from '@/interfaces';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
module: Module;
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
exercises: ExerciseGen[];
|
exercises: ExerciseGen[];
|
||||||
extraArgs?: Record<string, any>;
|
extraArgs?: Record<string, any>;
|
||||||
onSubmit: (configurations: ExerciseConfig[]) => void;
|
onSubmit: (configurations: ExerciseConfig[]) => void;
|
||||||
onDiscard: () => void;
|
onDiscard: () => void;
|
||||||
|
selectedExercises: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExerciseConfig {
|
export interface ExerciseConfig {
|
||||||
@@ -24,15 +27,14 @@ export interface ExerciseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ExerciseWizard: React.FC<Props> = ({
|
const ExerciseWizard: React.FC<Props> = ({
|
||||||
|
module,
|
||||||
exercises,
|
exercises,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
sectionId,
|
sectionId,
|
||||||
|
selectedExercises,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
}) => {
|
}) => {
|
||||||
const {currentModule} = useExamEditorStore();
|
|
||||||
const { selectedExercises } = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId === sectionId))!;
|
|
||||||
|
|
||||||
const [configurations, setConfigurations] = useState<ExerciseConfig[]>([]);
|
const [configurations, setConfigurations] = useState<ExerciseConfig[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,7 +238,7 @@ const ExerciseWizard: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={config.type}
|
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)}
|
{renderExerciseHeader(exercise, exerciseIndex, config, (exercise.extra || []).length > 2)}
|
||||||
|
|
||||||
@@ -262,7 +264,7 @@ const ExerciseWizard: React.FC<Props> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSubmit(configurations)}
|
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
|
Add Exercises
|
||||||
</button>
|
</button>
|
||||||
@@ -321,6 +321,12 @@ const EXERCISES: ExerciseGen[] = [
|
|||||||
type: "writing_letter",
|
type: "writing_letter",
|
||||||
icon: FaEnvelope,
|
icon: FaEnvelope,
|
||||||
extra: [
|
extra: [
|
||||||
|
{
|
||||||
|
label: "Letter Topic",
|
||||||
|
param: "topic",
|
||||||
|
value: "",
|
||||||
|
type: "text"
|
||||||
|
},
|
||||||
generate()
|
generate()
|
||||||
],
|
],
|
||||||
module: "writing"
|
module: "writing"
|
||||||
@@ -330,6 +336,12 @@ const EXERCISES: ExerciseGen[] = [
|
|||||||
type: "writing_2",
|
type: "writing_2",
|
||||||
icon: FaFileAlt,
|
icon: FaFileAlt,
|
||||||
extra: [
|
extra: [
|
||||||
|
{
|
||||||
|
label: "Essay Topic",
|
||||||
|
param: "topic",
|
||||||
|
value: "",
|
||||||
|
type: "text"
|
||||||
|
},
|
||||||
generate()
|
generate()
|
||||||
],
|
],
|
||||||
module: "writing"
|
module: "writing"
|
||||||
@@ -2,35 +2,39 @@ import EXERCISES from "./exercises";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ExerciseGen, GeneratedExercises, GeneratorState } from "./generatedExercises";
|
import { ExerciseGen, GeneratedExercises, GeneratorState } from "./generatedExercises";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
|
import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
|
||||||
import { generate } from "../../SettingsEditor/Shared/Generate";
|
import { generate } from "../SettingsEditor/Shared/Generate";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
|
import { LevelPart, ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
|
||||||
import { BsArrowRepeat } from "react-icons/bs";
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
|
import { writingTask } from "@/stores/examEditor/sections";
|
||||||
|
|
||||||
interface ExercisePickerProps {
|
interface ExercisePickerProps {
|
||||||
module: string;
|
module: string;
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
extraArgs?: Record<string, any>;
|
extraArgs?: Record<string, any>;
|
||||||
|
levelSectionId?: number;
|
||||||
|
level?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||||
module,
|
module,
|
||||||
sectionId,
|
sectionId,
|
||||||
extraArgs = undefined,
|
extraArgs = undefined,
|
||||||
|
levelSectionId,
|
||||||
|
level = false
|
||||||
}) => {
|
}) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
const { difficulty } = useExamEditorStore((store) => store.modules[currentModule]);
|
const { difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule]);
|
||||||
const section = useExamEditorStore((store) => store.modules[currentModule].sections.find((s) => s.sectionId == sectionId));
|
const section = sections.find((s) => s.sectionId === (level ? levelSectionId : sectionId));
|
||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [localSelectedExercises, setLocalSelectedExercises] = useState<string[]>([]);
|
||||||
|
|
||||||
if (section === undefined) return <></>;
|
const state = section?.state;
|
||||||
|
|
||||||
const { state, selectedExercises } = section;
|
|
||||||
|
|
||||||
const getFullExerciseType = (exercise: ExerciseGen): string => {
|
const getFullExerciseType = (exercise: ExerciseGen): string => {
|
||||||
if (exercise.extra && exercise.extra.length > 0) {
|
if (exercise.extra && exercise.extra.length > 0) {
|
||||||
@@ -43,19 +47,19 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
const handleChange = (exercise: ExerciseGen) => {
|
const handleChange = (exercise: ExerciseGen) => {
|
||||||
const fullType = getFullExerciseType(exercise);
|
const fullType = getFullExerciseType(exercise);
|
||||||
|
|
||||||
const newSelected = selectedExercises.includes(fullType)
|
setLocalSelectedExercises(prev => {
|
||||||
? selectedExercises.filter(type => type !== fullType)
|
const newSelected = prev.includes(fullType)
|
||||||
: [...selectedExercises, fullType];
|
? prev.filter(type => type !== fullType)
|
||||||
|
: [...prev, fullType];
|
||||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "selectedExercises", value: newSelected } })
|
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 exercises = configurations.map(config => {
|
||||||
const exerciseType = config.type.split('name=')[1];
|
const exerciseType = config.type.split('name=')[1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: exerciseType,
|
type: exerciseType,
|
||||||
quantity: Number(config.params.quantity || 1),
|
quantity: Number(config.params.quantity || 1),
|
||||||
@@ -68,56 +72,97 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let context, moduleState;
|
let context = {};
|
||||||
|
if (module === 'reading') {
|
||||||
switch (module) {
|
const readingState = state as ReadingPart | LevelPart;
|
||||||
case 'reading':
|
context = {
|
||||||
moduleState = state as ReadingPart;
|
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 = {
|
context = {
|
||||||
text: moduleState.text.content
|
text: dialog.map((d) => `${d.name}: ${d.text}`).join("\n")
|
||||||
}
|
};
|
||||||
break;
|
} else if (sectionId === 2 || sectionId === 4) {
|
||||||
case 'listening':
|
context = {
|
||||||
moduleState = state as ListeningPart;
|
text: script as string
|
||||||
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 = {}
|
|
||||||
}
|
}
|
||||||
generate(
|
if (!["speaking", "writing"].includes(module)) {
|
||||||
sectionId,
|
generate(
|
||||||
module as Module,
|
sectionId,
|
||||||
"exercises",
|
module as Module,
|
||||||
{
|
level ? `exercises-${module}` : "exercises",
|
||||||
method: 'POST',
|
{
|
||||||
body: {
|
method: 'POST',
|
||||||
...context,
|
body: {
|
||||||
exercises: exercises,
|
...context,
|
||||||
difficulty: difficulty
|
exercises: exercises,
|
||||||
|
difficulty: difficulty
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(data: any) => [{
|
||||||
|
exercises: data.exercises
|
||||||
|
}],
|
||||||
|
levelSectionId,
|
||||||
|
level
|
||||||
|
);
|
||||||
|
} else if (module === "writing") {
|
||||||
|
configurations.forEach((config) => {
|
||||||
|
let queryParams = config.params.topic !== '' ? { topic: config.params.topic as string } : undefined;
|
||||||
|
generate(
|
||||||
|
config.type === 'writing_letter' ? 1 : 2,
|
||||||
|
"writing",
|
||||||
|
config.type,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
queryParams
|
||||||
|
},
|
||||||
|
(data: any) => [{
|
||||||
|
prompt: data.question
|
||||||
|
}],
|
||||||
|
levelSectionId,
|
||||||
|
level
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
/*const newExercises = configurations.map((config) => {
|
||||||
|
switch (config.type) {
|
||||||
|
case 'writing_letter':
|
||||||
|
return { ...writingTask(1), level: true };
|
||||||
|
case 'writing_2':
|
||||||
|
return { ...writingTask(2), level: true };
|
||||||
}
|
}
|
||||||
},
|
return undefined;
|
||||||
(data: any) => [{
|
}).filter((ex) => ex !== undefined);
|
||||||
exercises: data.exercises
|
dispatch({
|
||||||
}]
|
type: "UPDATE_SECTION_STATE", payload: {
|
||||||
);
|
module: level ? "level" : module as Module, sectionId, update: {
|
||||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "selectedExercises", value: [] } })
|
exercises: [
|
||||||
|
...(sections.find((s) => s.sectionId = sectionId)?.state as LevelPart).exercises,
|
||||||
|
...newExercises
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})*/
|
||||||
|
}
|
||||||
|
setLocalSelectedExercises([]);
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
};
|
}, [
|
||||||
|
sectionId,
|
||||||
|
levelSectionId,
|
||||||
|
level,
|
||||||
|
module,
|
||||||
|
state,
|
||||||
|
difficulty,
|
||||||
|
setPickerOpen
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (section === undefined) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -131,6 +176,8 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ExerciseWizard
|
<ExerciseWizard
|
||||||
|
module={module as Module}
|
||||||
|
selectedExercises={localSelectedExercises}
|
||||||
sectionId={sectionId}
|
sectionId={sectionId}
|
||||||
exercises={moduleExercises}
|
exercises={moduleExercises}
|
||||||
onSubmit={onModuleSpecific}
|
onSubmit={onModuleSpecific}
|
||||||
@@ -151,7 +198,7 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="exercise"
|
name="exercise"
|
||||||
value={fullType}
|
value={fullType}
|
||||||
checked={selectedExercises.includes(fullType)}
|
checked={localSelectedExercises.includes(fullType)}
|
||||||
onChange={() => handleChange(exercise)}
|
onChange={() => handleChange(exercise)}
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
/>
|
/>
|
||||||
@@ -171,14 +218,14 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
disabled={selectedExercises.length == 0}
|
disabled={localSelectedExercises.length === 0}
|
||||||
>
|
>
|
||||||
{section.generating === "exercises" ? (
|
{section.generating === "exercises" ? (
|
||||||
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
||||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>Set Up Exercises ({selectedExercises.length}) </>
|
<>{["speaking", "writing"].includes(module) ? "Add Exercises" : "Set Up Exercises"} ({localSelectedExercises.length}) </>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,4 +234,4 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExercisePicker;
|
export default ExercisePicker;
|
||||||
@@ -47,7 +47,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
setEditing,
|
setEditing,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
editing,
|
editing,
|
||||||
setEditing,
|
setEditing,
|
||||||
@@ -74,7 +74,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
ex.id === exercise.id ? updatedExercise : ex
|
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: () => {
|
onDiscard: () => {
|
||||||
setSelectedBlankId(null);
|
setSelectedBlankId(null);
|
||||||
@@ -96,12 +96,23 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||||
|
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [answers, blanksState.blanks, blanksState.textMode])
|
}, [answers, blanksState.blanks, blanksState.textMode])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(()=> {
|
useEffect(()=> {
|
||||||
setEditingAlert(editing, setAlerts);
|
setEditingAlert(editing, setAlerts);
|
||||||
}, [editing])
|
}, [editing])
|
||||||
@@ -252,8 +261,10 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num
|
|||||||
onBlankRemove={handleBlankRemove}
|
onBlankRemove={handleBlankRemove}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDiscard={handleDiscard}
|
onDiscard={handleDiscard}
|
||||||
onDelete={modeHandle}
|
onDelete={handleDelete}
|
||||||
setEditing={setEditing}
|
setEditing={setEditing}
|
||||||
|
onPractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{!blanksState.textMode && <Card className="p-4">
|
{!blanksState.textMode && <Card className="p-4">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
setEditing,
|
setEditing,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
editing,
|
editing,
|
||||||
setEditing,
|
setEditing,
|
||||||
@@ -72,7 +72,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
ex.id === exercise.id ? updatedExercise : ex
|
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: () => {
|
onDiscard: () => {
|
||||||
setSelectedBlankId(null);
|
setSelectedBlankId(null);
|
||||||
@@ -92,12 +92,23 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
|
|||||||
}, [] as BlankState[]);
|
}, [] as BlankState[]);
|
||||||
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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)}
|
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDiscard={handleDiscard}
|
onDiscard={handleDiscard}
|
||||||
onDelete={modeHandle}
|
onDelete={handleDelete}
|
||||||
|
onPractice={handlePractice}
|
||||||
setEditing={setEditing}
|
setEditing={setEditing}
|
||||||
onBlankRemove={handleBlankRemove}
|
onBlankRemove={handleBlankRemove}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
>
|
>
|
||||||
{!blanksState.textMode && selectedBlankId && (
|
{!blanksState.textMode && selectedBlankId && (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import setEditingAlert from "../../Shared/setEditingAlert";
|
|||||||
import { blanksReducer } from "../BlanksReducer";
|
import { blanksReducer } from "../BlanksReducer";
|
||||||
import { validateWriteBlanks } from "./validation";
|
import { validateWriteBlanks } from "./validation";
|
||||||
import AlternativeSolutions from "./AlternativeSolutions";
|
import AlternativeSolutions from "./AlternativeSolutions";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
@@ -34,7 +33,7 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
|
|||||||
setEditing,
|
setEditing,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
editing,
|
editing,
|
||||||
setEditing,
|
setEditing,
|
||||||
@@ -57,19 +56,30 @@ const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: numb
|
|||||||
ex.id === exercise.id ? updatedExercise : ex
|
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: () => {
|
onDiscard: () => {
|
||||||
setSelectedBlankId(null);
|
setSelectedBlankId(null);
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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}
|
onBlankRemove={handleBlankRemove}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDiscard={handleDiscard}
|
onDiscard={handleDiscard}
|
||||||
onDelete={modeHandle}
|
onDelete={handleDelete}
|
||||||
|
onPractice={handlePractice}
|
||||||
setEditing={setEditing}
|
setEditing={setEditing}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
>
|
>
|
||||||
{!blanksState.textMode && (
|
{!blanksState.textMode && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface Props {
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onDiscard: () => void;
|
onDiscard: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onPractice: () => void;
|
||||||
|
isEvaluationEnabled?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,8 @@ const BlanksEditor: React.FC<Props> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onPractice,
|
||||||
|
isEvaluationEnabled,
|
||||||
setEditing
|
setEditing
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
@@ -161,8 +165,10 @@ const BlanksEditor: React.FC<Props> = ({
|
|||||||
description={description}
|
description={description}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={onSave}
|
handleSave={onSave}
|
||||||
modeHandle={onDelete}
|
handleDelete={onDelete}
|
||||||
handleDiscard={onDiscard}
|
handleDiscard={onDiscard}
|
||||||
|
handlePractice={onPractice}
|
||||||
|
isEvaluationEnabled={isEvaluationEnabled}
|
||||||
/>
|
/>
|
||||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
|||||||
setEditing(true);
|
setEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
const { editing, setEditing, handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
|
|
||||||
@@ -53,19 +53,30 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
|||||||
|
|
||||||
const newState = { ...section };
|
const newState = { ...section };
|
||||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex);
|
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: () => {
|
onDiscard: () => {
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
setSelectedParagraph(null);
|
setSelectedParagraph(null);
|
||||||
setShowReference(false);
|
setShowReference(false);
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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`}
|
description={`Edit ${exercise.variant && exercise.variant == "ideaMatch" ? "ideas/opinions" : "headings"} and their matches`}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleDelete={handleDelete}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
|
handlePractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReference(!showReference)}
|
onClick={() => setShowReference(!showReference)}
|
||||||
@@ -210,7 +223,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu
|
|||||||
</SortableQuestion>
|
</SortableQuestion>
|
||||||
))}
|
))}
|
||||||
</QuestionsList>
|
</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
|
<button
|
||||||
onClick={addHeading}
|
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"
|
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"
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
|||||||
updateLocal({ ...local, questions: newQuestions });
|
updateLocal({ ...local, questions: newQuestions });
|
||||||
};
|
};
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
@@ -85,20 +84,31 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
|||||||
ex.id === local.id ? local : 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: () => {
|
onDiscard: () => {
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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 (
|
return (
|
||||||
@@ -108,8 +118,10 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti
|
|||||||
description="Edit questions with 4 underline options each"
|
description="Edit questions with 4 underline options each"
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleDelete={handleDelete}
|
||||||
|
handlePractice={handlePractice}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
/>
|
/>
|
||||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
|
|
||||||
|
|||||||
@@ -143,9 +143,8 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
|||||||
updateLocal({ ...local, questions: updatedQuestions });
|
updateLocal({ ...local, questions: updatedQuestions });
|
||||||
};
|
};
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
const isValid = validateMultipleChoiceQuestions(
|
const isValid = validateMultipleChoiceQuestions(
|
||||||
local.questions,
|
local.questions,
|
||||||
@@ -164,20 +163,31 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
|||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
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: () => {
|
onDiscard: () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -197,8 +207,10 @@ const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, op
|
|||||||
description={`Edit questions with ${optionsQuantity} options each`}
|
description={`Edit questions with ${optionsQuantity} options each`}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleDelete={handleDelete}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
|
handlePractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
/>
|
/>
|
||||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const colorOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
|
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
|
||||||
|
|
||||||
const isConversation = [1, 3].includes(section);
|
const isConversation = [1, 3].includes(section);
|
||||||
const speakerCount = section === 1 ? 2 : 4;
|
const speakerCount = section === 1 ? 2 : 4;
|
||||||
|
|
||||||
|
|||||||
@@ -12,53 +12,70 @@ import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
|||||||
import { BsFileText } from "react-icons/bs";
|
import { BsFileText } from "react-icons/bs";
|
||||||
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
|
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
|
||||||
import { RiVideoLine } from "react-icons/ri";
|
import { RiVideoLine } from "react-icons/ri";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
exercise: InteractiveSpeakingExercise;
|
exercise: InteractiveSpeakingExercise;
|
||||||
|
module?: Module;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { dispatch } = useExamEditorStore();
|
||||||
const [local, setLocal] = useState(exercise);
|
const [local, setLocal] = useState(exercise);
|
||||||
|
|
||||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
||||||
|
|
||||||
const { generating, genResult } = useExamEditorStore(
|
const { generating, genResult, state } = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(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,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local, module: module } });
|
||||||
|
if (genResult) {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: {
|
||||||
|
sectionId,
|
||||||
|
module: module,
|
||||||
|
field: "genResult",
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDiscard: () => {
|
onDiscard: () => {
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
},
|
},
|
||||||
onMode: () => { },
|
onPractice: () => {
|
||||||
|
const updatedExercise = {
|
||||||
|
...state,
|
||||||
|
isPractice: !local.isPractice
|
||||||
|
};
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult && generating === "context") {
|
if (genResult && generating === "speakingScript") {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setLocal({
|
setLocal({
|
||||||
...local,
|
...local,
|
||||||
title: genResult[0].title,
|
title: genResult.result[0].title,
|
||||||
prompts: genResult[0].prompts.map((item: any) => ({
|
prompts: genResult.result[0].prompts.map((item: any) => ({
|
||||||
text: item || "",
|
text: item || "",
|
||||||
video_url: ""
|
video_url: ""
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId,
|
sectionId,
|
||||||
module: currentModule,
|
module: module,
|
||||||
field: "genResult",
|
field: "generating",
|
||||||
value: undefined
|
value: undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -91,27 +108,18 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
const isUnedited = local.prompts.length === 0;
|
const isUnedited = local.prompts.length === 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult && generating === "media") {
|
if (genResult && generating === "video") {
|
||||||
setLocal({ ...local, prompts: genResult[0].prompts });
|
setLocal({ ...local, prompts: genResult.result[0].prompts });
|
||||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } });
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module: module } });
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId,
|
sectionId,
|
||||||
module: currentModule,
|
module: module,
|
||||||
field: "generating",
|
field: "generating",
|
||||||
value: undefined
|
value: undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
|
||||||
payload: {
|
|
||||||
sectionId,
|
|
||||||
module: currentModule,
|
|
||||||
field: "genResult",
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [genResult, generating]);
|
}, [genResult, generating]);
|
||||||
@@ -121,7 +129,7 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNextVideo = () => {
|
const handleNextVideo = () => {
|
||||||
setCurrentVideoIndex((prev) =>
|
setCurrentVideoIndex((prev) =>
|
||||||
(prev < local.prompts.length - 1 ? prev + 1 : prev)
|
(prev < local.prompts.length - 1 ? prev + 1 : prev)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -134,14 +142,15 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
description='Generate or write the scripts for the videos.'
|
description='Generate or write the scripts for the videos.'
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleEdit={handleEdit}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
mode="edit"
|
handlePractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{generating && generating === "context" ? (
|
{generating && generating === "speakingScript" ? (
|
||||||
<GenLoader module={currentModule} />
|
<GenLoader module={module} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -160,8 +169,8 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
onClick={handlePrevVideo}
|
onClick={handlePrevVideo}
|
||||||
disabled={currentVideoIndex === 0}
|
disabled={currentVideoIndex === 0}
|
||||||
className={`p-2 rounded-full ${currentVideoIndex === 0
|
className={`p-2 rounded-full ${currentVideoIndex === 0
|
||||||
? 'text-gray-400 cursor-not-allowed'
|
? 'text-gray-400 cursor-not-allowed'
|
||||||
: 'text-gray-600 hover:bg-gray-100'
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FaChevronLeft className="w-4 h-4" />
|
<FaChevronLeft className="w-4 h-4" />
|
||||||
@@ -173,8 +182,8 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
onClick={handleNextVideo}
|
onClick={handleNextVideo}
|
||||||
disabled={currentVideoIndex === local.prompts.length - 1}
|
disabled={currentVideoIndex === local.prompts.length - 1}
|
||||||
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
|
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
|
||||||
? 'text-gray-400 cursor-not-allowed'
|
? 'text-gray-400 cursor-not-allowed'
|
||||||
: 'text-gray-600 hover:bg-gray-100'
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FaChevronRight className="w-4 h-4" />
|
<FaChevronRight className="w-4 h-4" />
|
||||||
@@ -196,8 +205,8 @@ const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{generating && generating === "media" &&
|
{generating && generating === "video" &&
|
||||||
<GenLoader module={currentModule} custom="Generating the videos ... This may take a while ..." />
|
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
|
||||||
}
|
}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
|||||||
import { BsFileText } from "react-icons/bs";
|
import { BsFileText } from "react-icons/bs";
|
||||||
import { RiVideoLine } from 'react-icons/ri';
|
import { RiVideoLine } from 'react-icons/ri';
|
||||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
|
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
|
||||||
|
import { Module } from '@/interfaces';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
exercise: InteractiveSpeakingExercise;
|
exercise: InteractiveSpeakingExercise;
|
||||||
|
module?: Module;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
|
const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const {dispatch} = useExamEditorStore();
|
||||||
const [local, setLocal] = useState(() => {
|
const [local, setLocal] = useState(() => {
|
||||||
const defaultPrompts = [
|
const defaultPrompts = [
|
||||||
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
|
{ 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 [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
||||||
|
|
||||||
const { generating, genResult } = useExamEditorStore(
|
const { generating, genResult , state} = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(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,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
setEditing(false);
|
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: () => {
|
onDiscard: () => {
|
||||||
setLocal({
|
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(() => {
|
useEffect(() => {
|
||||||
if (genResult && generating === "context") {
|
if (genResult && generating === "speakingScript") {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setLocal(prev => ({
|
setLocal(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
first_title: genResult[0].first_topic,
|
first_title: genResult.result[0].first_topic,
|
||||||
second_title: genResult[0].second_topic,
|
second_title: genResult.result[0].second_topic,
|
||||||
prompts: [
|
prompts: [
|
||||||
prev.prompts[0],
|
prev.prompts[0],
|
||||||
prev.prompts[1],
|
prev.prompts[1],
|
||||||
...genResult[0].prompts.map((item: any) => ({
|
...genResult.result[0].prompts.map((item: any) => ({
|
||||||
text: item,
|
text: item,
|
||||||
video_url: ""
|
video_url: ""
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId,
|
sectionId,
|
||||||
module: currentModule,
|
module: module,
|
||||||
field: "genResult",
|
field: "generating",
|
||||||
value: undefined
|
value: undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -111,15 +128,14 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult && generating === "media") {
|
if (genResult && generating === "video") {
|
||||||
console.log(genResult[0].prompts);
|
setLocal({ ...local, prompts: genResult.result[0].prompts });
|
||||||
setLocal({ ...local, prompts: genResult[0].prompts });
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module } });
|
||||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult[0].prompts } } });
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId,
|
sectionId,
|
||||||
module: currentModule,
|
module: module,
|
||||||
field: "generating",
|
field: "generating",
|
||||||
value: undefined
|
value: undefined
|
||||||
}
|
}
|
||||||
@@ -128,7 +144,7 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId,
|
sectionId,
|
||||||
module: currentModule,
|
module: module,
|
||||||
field: "genResult",
|
field: "genResult",
|
||||||
value: undefined
|
value: undefined
|
||||||
}
|
}
|
||||||
@@ -155,14 +171,15 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
description='Generate or write the scripts for the videos.'
|
description='Generate or write the scripts for the videos.'
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleEdit={handleEdit}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
mode="edit"
|
handlePractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{generating && generating === "context" ? (
|
{generating && generating === "speakingScript" ? (
|
||||||
<GenLoader module={currentModule} />
|
<GenLoader module={module} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -305,8 +322,8 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{generating && generating === "media" &&
|
{generating && generating === "video" &&
|
||||||
<GenLoader module={currentModule} custom="Generating the videos ... This may take a while ..." />
|
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
|
||||||
}
|
}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|||||||
@@ -12,52 +12,69 @@ import { AiOutlineUnorderedList } from 'react-icons/ai';
|
|||||||
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
|
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
|
||||||
import GenLoader from "../Shared/GenLoader";
|
import GenLoader from "../Shared/GenLoader";
|
||||||
import { RiVideoLine } from 'react-icons/ri';
|
import { RiVideoLine } from 'react-icons/ri';
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
exercise: SpeakingExercise;
|
exercise: SpeakingExercise;
|
||||||
|
module?: Module;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
|
const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { dispatch } = useExamEditorStore();
|
||||||
const [local, setLocal] = useState(exercise);
|
const [local, setLocal] = useState(exercise);
|
||||||
|
|
||||||
const { generating, genResult } = useExamEditorStore(
|
const { generating, genResult, state } = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(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,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
console.log(local);
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local , module} });
|
||||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
if (genResult) {
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: {
|
||||||
|
sectionId,
|
||||||
|
module: module,
|
||||||
|
field: "genResult",
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDiscard: () => {
|
onDiscard: () => {
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
},
|
},
|
||||||
onMode: () => { },
|
onPractice: () => {
|
||||||
|
const updatedExercise = {
|
||||||
|
...state,
|
||||||
|
isPractice: !local.isPractice
|
||||||
|
};
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: module } });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult && generating === "context") {
|
if (genResult && generating === "speakingScript") {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setLocal({
|
setLocal({
|
||||||
...local,
|
...local,
|
||||||
title: genResult[0].topic,
|
title: genResult.result[0].topic,
|
||||||
text: genResult[0].question,
|
text: genResult.result[0].question,
|
||||||
prompts: genResult[0].prompts
|
prompts: genResult.result[0].prompts
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId,
|
sectionId,
|
||||||
module: currentModule,
|
module: module,
|
||||||
field: "genResult",
|
field: "generating",
|
||||||
value: undefined
|
value: undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -66,27 +83,18 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
}, [genResult, generating]);
|
}, [genResult, generating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult && generating === "media") {
|
if (genResult && generating === "video") {
|
||||||
setLocal({...local, video_url: genResult[0].video_url});
|
setLocal({...local, video_url: genResult.result[0].video_url});
|
||||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult[0].video_url} } });
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: {...local, video_url: genResult.result[0].video_url} , module} });
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
payload: {
|
payload: {
|
||||||
sectionId,
|
sectionId,
|
||||||
module: currentModule,
|
module: module,
|
||||||
field: "generating",
|
field: "generating",
|
||||||
value: undefined
|
value: undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
|
||||||
payload: {
|
|
||||||
sectionId,
|
|
||||||
module: currentModule,
|
|
||||||
field: "genResult",
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [genResult, generating]);
|
}, [genResult, generating]);
|
||||||
@@ -138,14 +146,15 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
description='Generate or write the script for the video.'
|
description='Generate or write the script for the video.'
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleEdit={handleEdit}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
mode="edit"
|
handlePractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
module="speaking"
|
module="speaking"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{generating && generating === "context" ? (
|
{generating && generating === "speakingScript" ? (
|
||||||
<GenLoader module={currentModule} />
|
<GenLoader module={module} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -260,8 +269,8 @@ const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
}
|
}
|
||||||
{generating && generating === "media" &&
|
{generating && generating === "video" &&
|
||||||
<GenLoader module={currentModule} custom="Generating the video ... This may take a while ..." />
|
<GenLoader module={module} custom="Generating the video ... This may take a while ..." />
|
||||||
}
|
}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { ModuleState } from "@/stores/examEditor/types";
|
|
||||||
import { SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
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 Speaking2 from "./Speaking2";
|
||||||
import InteractiveSpeaking from "./InteractiveSpeaking";
|
import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||||
import Speaking1 from "./Speaking1";
|
import Speaking1 from "./Speaking1";
|
||||||
|
|||||||
@@ -72,9 +72,8 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
|||||||
updateLocal({ ...local, questions: updatedQuestions });
|
updateLocal({ ...local, questions: updatedQuestions });
|
||||||
};
|
};
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
const isValid = validateTrueFalseQuestions(
|
const isValid = validateTrueFalseQuestions(
|
||||||
local.questions,
|
local.questions,
|
||||||
@@ -93,18 +92,29 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
|||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
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: () => {
|
onDiscard: () => {
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -127,8 +137,9 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> =
|
|||||||
description='Edit questions and their solutions'
|
description='Edit questions and their solutions'
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleDelete={handleDelete}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
|
handlePractice={handlePractice}
|
||||||
/>
|
/>
|
||||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||||
<PromptEdit
|
<PromptEdit
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
|
|||||||
import { ParsedQuestion, parseText, reconstructText } from './parsing';
|
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 { currentModule, dispatch } = useExamEditorStore();
|
||||||
const { state } = useExamEditorStore(
|
const { state } = useExamEditorStore(
|
||||||
@@ -38,9 +38,8 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
|||||||
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
|
||||||
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
const isQuestionTextValid = validateQuestionText(
|
const isQuestionTextValid = validateQuestionText(
|
||||||
parsedQuestions,
|
parsedQuestions,
|
||||||
@@ -65,18 +64,29 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
|||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
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: () => {
|
onDiscard: () => {
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -222,12 +232,14 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise;
|
|||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Header
|
<Header
|
||||||
title={title}
|
title={"Write Blanks: Questions"}
|
||||||
description="Edit questions and their solutions"
|
description="Edit questions and their solutions"
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
modeHandle={modeHandle}
|
handleDelete={handleDelete}
|
||||||
|
handlePractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
|||||||
const [editingPrompt, setEditingPrompt] = useState(false);
|
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||||
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
|
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
|
||||||
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
|
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
|
||||||
@@ -50,19 +49,30 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
|||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
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: () => {
|
onDiscard: () => {
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
setParsedQuestions([]);
|
setParsedQuestions([]);
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onDelete: () => {
|
||||||
const newSection = {
|
const newSection = {
|
||||||
...section,
|
...section,
|
||||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -204,7 +214,8 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci
|
|||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
modeHandle={modeHandle}
|
handleDelete={handleDelete}
|
||||||
|
handlePractice={handlePractice}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||||
|
|||||||
@@ -2,67 +2,87 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import ExamEditorStore, { ModuleState } from "@/stores/examEditor/types";
|
import ExamEditorStore, { ModuleState } from "@/stores/examEditor/types";
|
||||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
import { WritingExercise } from "@/interfaces/exam";
|
import { LevelPart, WritingExercise } from "@/interfaces/exam";
|
||||||
import Header from "../../Shared/Header";
|
import Header from "../../Shared/Header";
|
||||||
import Alert, { AlertItem } from "../Shared/Alert";
|
import Alert, { AlertItem } from "../Shared/Alert";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
import GenLoader from "../Shared/GenLoader";
|
import GenLoader from "../Shared/GenLoader";
|
||||||
import setEditingAlert from "../Shared/setEditingAlert";
|
import setEditingAlert from "../Shared/setEditingAlert";
|
||||||
|
import { Module } from "@/interfaces";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
exercise: WritingExercise;
|
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 { currentModule, dispatch } = useExamEditorStore();
|
||||||
const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
|
|
||||||
const { generating, genResult, state } = useExamEditorStore(
|
const { generating, genResult, state } = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
);
|
);
|
||||||
|
|
||||||
const [local, setLocal] = useState(exercise);
|
const [local, setLocal] = useState(exercise);
|
||||||
const [prompt, setPrompt] = useState(exercise.prompt);
|
const [prompt, setPrompt] = useState(exercise.prompt);
|
||||||
const [loading, setLoading] = useState(generating && generating == "exercises");
|
const [loading, setLoading] = useState(generating && generating == "exercises");
|
||||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
|
||||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
const level = module === "level";
|
||||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, handleEdit, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
const newExercise = { ...local } as WritingExercise;
|
const newExercise = { ...local } as WritingExercise;
|
||||||
newExercise.prompt = prompt;
|
newExercise.prompt = prompt;
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
setEditing(false);
|
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: () => {
|
onDiscard: () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setLocal(exercise);
|
setLocal(exercise);
|
||||||
setPrompt(exercise.prompt);
|
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(() => {
|
useEffect(() => {
|
||||||
const loading = generating && generating == "context";
|
const loading = generating && generating == "writing";
|
||||||
setLoading(loading);
|
setLoading(loading);
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
updateModule({ edit: Array.from(new Set(edit).add(sectionId)) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [generating, updateModule]);
|
}, [generating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult !== undefined && generating === "context") {
|
if (genResult) {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setPrompt(genResult[0].prompt);
|
setPrompt(genResult.result[0].prompt);
|
||||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||||
@@ -73,20 +93,22 @@ const Writing: React.FC<Props> = ({ sectionId, exercise }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='relative pb-4'>
|
<div className={clsx('relative', level ? "px-4 mt-2" : "pb-2")}>
|
||||||
<Header
|
<Header
|
||||||
title={`Task ${sectionId} Instructions`}
|
title={`${sectionId === 1 ? "Letter" : "Essay"} Instructions`}
|
||||||
description='Generate or edit the instructions for the task'
|
description='Generate or edit the instructions for the task'
|
||||||
editing={editing}
|
editing={editing}
|
||||||
handleSave={handleSave}
|
handleSave={handleSave}
|
||||||
modeHandle={modeHandle}
|
handleDelete={handleDelete}
|
||||||
|
handleEdit={handleEdit}
|
||||||
handleDiscard={handleDiscard}
|
handleDiscard={handleDiscard}
|
||||||
mode="edit"
|
handlePractice={handlePractice}
|
||||||
|
isEvaluationEnabled={!local.isPractice}
|
||||||
module={"writing"}
|
module={"writing"}
|
||||||
/>
|
/>
|
||||||
{alerts.length !== 0 && <Alert alerts={alerts} />}
|
{alerts.length !== 0 && <Alert alerts={alerts} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className={clsx(level ? "mt-2 px-4" : "mt-4")}>
|
||||||
{loading ?
|
{loading ?
|
||||||
<GenLoader module={currentModule} /> :
|
<GenLoader module={currentModule} /> :
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { useCallback, useState } from 'react';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
mode?: "delete" | "edit";
|
|
||||||
editing?: boolean;
|
editing?: boolean;
|
||||||
setEditing?: React.Dispatch<React.SetStateAction<boolean>>;
|
setEditing?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onDiscard?: () => void;
|
onDiscard?: () => void;
|
||||||
onMode?: () => void;
|
onDelete?: () => void;
|
||||||
|
onPractice?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSectionEdit = ({
|
const useSectionEdit = ({
|
||||||
@@ -18,7 +19,9 @@ const useSectionEdit = ({
|
|||||||
setEditing: externalSetEditing,
|
setEditing: externalSetEditing,
|
||||||
onSave,
|
onSave,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onMode
|
onDelete,
|
||||||
|
onPractice,
|
||||||
|
onEdit
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { dispatch } = useExamEditorStore();
|
const { dispatch } = useExamEditorStore();
|
||||||
const [internalEditing, setInternalEditing] = useState<boolean>(externalEditing);
|
const [internalEditing, setInternalEditing] = useState<boolean>(externalEditing);
|
||||||
@@ -31,9 +34,12 @@ const useSectionEdit = ({
|
|||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
const handleEdit = useCallback(() => {
|
||||||
setEditing(true);
|
setEditing(!editing);
|
||||||
|
if (onEdit) {
|
||||||
|
onEdit();
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sectionId, setEditing, updateRoot]);
|
}, [sectionId, editing, setEditing, updateRoot]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
@@ -51,12 +57,18 @@ const useSectionEdit = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [setEditing, updateRoot, onDiscard, sectionId]);
|
}, [setEditing, updateRoot, onDiscard, sectionId]);
|
||||||
|
|
||||||
const modeHandle = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
setEditing(!editing);
|
setEditing(!editing);
|
||||||
onMode?.();
|
onDelete?.();
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 {
|
return {
|
||||||
editing,
|
editing,
|
||||||
@@ -64,7 +76,8 @@ const useSectionEdit = ({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDiscard,
|
handleDiscard,
|
||||||
modeHandle,
|
handleDelete,
|
||||||
|
handlePractice,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,14 @@ const useSettingsState = <T extends SectionSettings>(
|
|||||||
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_SECTION_SETTINGS',
|
type: 'UPDATE_SECTION_SETTINGS',
|
||||||
payload: { sectionId, update: pendingUpdatesRef.current}
|
payload: { sectionId, update: pendingUpdatesRef.current, module}
|
||||||
});
|
});
|
||||||
pendingUpdatesRef.current = {};
|
pendingUpdatesRef.current = {};
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return debouncedFn;
|
return debouncedFn;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dispatch, sectionId]);
|
}, [dispatch, sectionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,7 +53,7 @@ const useSettingsState = <T extends SectionSettings>(
|
|||||||
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
if (Object.keys(pendingUpdatesRef.current).length > 0) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'UPDATE_SECTION_SETTINGS',
|
type: 'UPDATE_SECTION_SETTINGS',
|
||||||
payload: {sectionId, update: pendingUpdatesRef.current}
|
payload: {sectionId, update: pendingUpdatesRef.current, module}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FaPencilAlt } from 'react-icons/fa';
|
|||||||
import { Module } from '@/interfaces';
|
import { Module } from '@/interfaces';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import WordUploader from './WordUploader';
|
import WordUploader from './WordUploader';
|
||||||
import GenLoader from '../../Exercises/Shared/GenLoader';
|
import GenLoader from '../Exercises/Shared/GenLoader';
|
||||||
import useExamEditorStore from '@/stores/examEditor';
|
import useExamEditorStore from '@/stores/examEditor';
|
||||||
|
|
||||||
const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => {
|
const ImportOrFromScratch: React.FC<{ module: Module; }> = ({ module }) => {
|
||||||
@@ -1,40 +1,41 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import ExamEditorStore from "@/stores/examEditor/types";
|
import ExamEditorStore, { Generating } from "@/stores/examEditor/types";
|
||||||
import Header from "../../Shared/Header";
|
import Header from "../../Shared/Header";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
|
import GenLoader from "../../Exercises/Shared/GenLoader";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
renderContent: (editing: boolean) => React.ReactNode;
|
renderContent: (editing: boolean, listeningSection?: number) => React.ReactNode;
|
||||||
mode?: "edit" | "delete";
|
mode?: "edit" | "delete";
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onDiscard: () => void;
|
onDiscard: () => void;
|
||||||
onEdit?: () => 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 SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context, listeningSection }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule } = useExamEditorStore();
|
||||||
const { generating } = useExamEditorStore(
|
const { generating, levelGenerating } = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
);
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(generating && generating == "context");
|
const [loading, setLoading] = useState(generating && generating === context);
|
||||||
|
|
||||||
const updateRoot = useCallback((updates: Partial<ExamEditorStore>) => {
|
|
||||||
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loading = generating && generating == "context";
|
const gen = module === "level" ? levelGenerating.find(g => g === context) !== undefined : generating && generating === context;
|
||||||
setLoading(loading);
|
if (loading !== gen) {
|
||||||
|
setLoading(gen);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [generating, updateRoot]);
|
}, [generating, levelGenerating]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
|
<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}
|
editing={editing}
|
||||||
handleSave={onSave}
|
handleSave={onSave}
|
||||||
handleDiscard={onDiscard}
|
handleDiscard={onDiscard}
|
||||||
modeHandle={onEdit}
|
handleEdit={onEdit}
|
||||||
mode={mode}
|
|
||||||
module={module}
|
module={module}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="w-full cursor-text px-7 py-8 border-2 border-mti-gray-platinum bg-white rounded-3xl">
|
<GenLoader module={module} />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
renderContent(editing)
|
renderContent(editing, listeningSection)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ListeningPart } from "@/interfaces/exam";
|
import { LevelPart, ListeningPart } from "@/interfaces/exam";
|
||||||
import SectionContext from ".";
|
import SectionContext from ".";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
@@ -9,25 +9,42 @@ import Dropdown from "@/components/Dropdown";
|
|||||||
import AudioPlayer from "@/components/Low/AudioPlayer";
|
import AudioPlayer from "@/components/Low/AudioPlayer";
|
||||||
import { MdHeadphones } from "react-icons/md";
|
import { MdHeadphones } from "react-icons/md";
|
||||||
import clsx from "clsx";
|
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 ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection, level = false }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { dispatch } = useExamEditorStore();
|
||||||
const { genResult, state, generating } = useExamEditorStore(
|
const { genResult, state, generating, levelGenResults, levelGenerating } = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(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 [scriptLocal, setScriptLocal] = useState(listeningPart.script);
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, setEditing, handleEdit } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
const newState = { ...listeningPart };
|
const newState = { ...listeningPart };
|
||||||
newState.script = scriptLocal;
|
newState.script = scriptLocal;
|
||||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } })
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
|
||||||
setEditing(false);
|
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: () => {
|
onDiscard: () => {
|
||||||
setScriptLocal(listeningPart.script);
|
setScriptLocal(listeningPart.script);
|
||||||
@@ -35,15 +52,42 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult !== undefined && generating === "context") {
|
if (genResult && generating === "listeningScript") {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setScriptLocal(genResult[0].script);
|
setScriptLocal(genResult.result[0].script);
|
||||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
setIsDialogDropdownOpen(true);
|
||||||
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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) {
|
if (scriptLocal === undefined && !editing) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -53,16 +97,18 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{generating === "audio" ? (<GenLoader module="listening" custom="Generating audio ..." />) : (
|
||||||
{listeningPart.audio?.source && (
|
<>
|
||||||
<AudioPlayer
|
{listeningPart.audio?.source && (
|
||||||
key={sectionId}
|
<AudioPlayer
|
||||||
src={listeningPart.audio?.source ?? ''}
|
key={sectionId}
|
||||||
color="listening"
|
src={listeningPart.audio?.source ?? ''}
|
||||||
/>
|
color="listening"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Dropdown
|
<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"
|
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"
|
||||||
@@ -71,16 +117,22 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<MdHeadphones className={clsx(
|
<MdHeadphones className={clsx(
|
||||||
"h-5 w-5",
|
"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>
|
</div>
|
||||||
}
|
}
|
||||||
|
open={isDialogDropdownOpen}
|
||||||
|
setIsOpen={setIsDialogDropdownOpen}
|
||||||
>
|
>
|
||||||
<ScriptRender
|
<ScriptRender
|
||||||
local={scriptLocal}
|
local={scriptLocal}
|
||||||
setLocal={setScriptLocal}
|
setLocal={setScriptLocal}
|
||||||
section={sectionId}
|
section={level ? listeningSection! : sectionId}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -91,14 +143,20 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
return (
|
return (
|
||||||
<SectionContext
|
<SectionContext
|
||||||
sectionId={sectionId}
|
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`}
|
description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`}
|
||||||
renderContent={renderContent}
|
renderContent={renderContent}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onEdit={modeHandle}
|
onEdit={handleEdit}
|
||||||
onDiscard={handleDiscard}
|
onDiscard={handleDiscard}
|
||||||
module={currentModule}
|
module={module}
|
||||||
|
context="listeningScript"
|
||||||
|
listeningSection={listeningSection}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,53 +1,81 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ReadingPart } from "@/interfaces/exam";
|
import { LevelPart, ReadingPart } from "@/interfaces/exam";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
import Passage from "../../Shared/Passage";
|
import Passage from "../../Shared/Passage";
|
||||||
import SectionContext from ".";
|
import SectionContext from ".";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
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 ReadingContext: React.FC<Props> = ({ sectionId, module, level = false }) => {
|
||||||
const {currentModule, dispatch } = useExamEditorStore();
|
const { dispatch } = useExamEditorStore();
|
||||||
const { genResult, state, generating } = useExamEditorStore(
|
const sectionState = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
|
||||||
);
|
);
|
||||||
const readingPart = state as ReadingPart;
|
|
||||||
|
|
||||||
const [title, setTitle] = useState(readingPart.text.title);
|
const { genResult, state, levelGenResults, levelGenerating } = sectionState;
|
||||||
const [content, setContent] = useState(readingPart.text.content);
|
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 [passageOpen, setPassageOpen] = useState(false);
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, handleEdit, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
let newState = {...state} as ReadingPart;
|
let newState = { ...state } as ReadingPart | LevelPart;
|
||||||
newState.text.title = title;
|
newState.text = {
|
||||||
newState.text.content = content;
|
title, content
|
||||||
dispatch({type: 'UPDATE_SECTION_STATE', payload: {sectionId, update: newState}})
|
}
|
||||||
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
|
||||||
setEditing(false);
|
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: () => {
|
onDiscard: () => {
|
||||||
setTitle(readingPart.text.title);
|
setTitle(readingPart.text?.title || '');
|
||||||
setContent(readingPart.text.content);
|
setContent(readingPart.text?.content || '');
|
||||||
},
|
},
|
||||||
onMode: () => {
|
onEdit: () => {
|
||||||
setPassageOpen(false);
|
setPassageOpen(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(()=> {
|
useEffect(() => {
|
||||||
if (genResult !== undefined && generating === "context") {
|
if (genResult && genResult.generating === "passage") {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
console.log(genResult);
|
setTitle(genResult.result[0].title);
|
||||||
setTitle(genResult[0].title);
|
setContent(genResult.result[0].text);
|
||||||
setContent(genResult[0].text);
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, 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
|
// 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) => {
|
const renderContent = (editing: boolean) => {
|
||||||
@@ -98,9 +126,10 @@ const ReadingContext: React.FC<{sectionId: number;}> = ({sectionId}) => {
|
|||||||
renderContent={renderContent}
|
renderContent={renderContent}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onEdit={modeHandle}
|
onEdit={handleEdit}
|
||||||
module={currentModule}
|
module={module}
|
||||||
onDiscard={handleDiscard}
|
onDiscard={handleDiscard}
|
||||||
|
context="passage"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import SortableSection from "../../Shared/SortableSection";
|
import SortableSection from "../../Shared/SortableSection";
|
||||||
import getReadingQuestions from '../SectionExercises/reading';
|
|
||||||
import { Exercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
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 Dropdown from "@/components/Dropdown";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import Writing from "../../Exercises/Writing";
|
import Writing from "../../Exercises/Writing";
|
||||||
@@ -13,15 +12,14 @@ import {
|
|||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent,
|
|
||||||
closestCenter,
|
closestCenter,
|
||||||
UniqueIdentifier,
|
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import GenLoader from "../../Exercises/Shared/GenLoader";
|
import GenLoader from "../../Exercises/Shared/GenLoader";
|
||||||
import { ExamPart } from "@/stores/examEditor/types";
|
import { ExamPart, Generating } from "@/stores/examEditor/types";
|
||||||
import getListeningItems from "./listening";
|
|
||||||
import getLevelQuestionItems from "./level";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import getExerciseItems from "./exercises";
|
||||||
|
import { Action } from "@/stores/examEditor/reducers";
|
||||||
|
import { writingTask } from "@/stores/examEditor/sections";
|
||||||
|
|
||||||
|
|
||||||
interface QuestionItemsResult {
|
interface QuestionItemsResult {
|
||||||
@@ -30,31 +28,135 @@ interface QuestionItemsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const dispatch = useExamEditorStore(state => state.dispatch);
|
||||||
const { sections, expandedSections } = useExamEditorStore(
|
const currentModule = useExamEditorStore(state => state.currentModule);
|
||||||
(state) => state.modules[currentModule]
|
|
||||||
);
|
const sections = useExamEditorStore(state => state.modules[currentModule].sections);
|
||||||
|
const expandedSections = useExamEditorStore(state => state.modules[currentModule].expandedSections);
|
||||||
const { genResult, generating, state } = useExamEditorStore(
|
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
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(() => {
|
useEffect(() => {
|
||||||
if (genResult !== undefined && generating === "exercises") {
|
if (genResult && genResult.generating === "exercises" && genResult.module === currentModule) {
|
||||||
const newExercises = genResult[0].exercises;
|
const newExercises = genResult.result[0].exercises;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_STATE", payload: {
|
type: "UPDATE_SECTION_STATE", payload: {
|
||||||
sectionId, update: {
|
sectionId,
|
||||||
exercises: [...(state as ExamPart).exercises, ...newExercises]
|
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 } })
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [genResult, dispatch, sectionId, currentModule]);
|
}, [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 currentSection = sections.find((s) => s.sectionId === sectionId)!;
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -62,42 +164,12 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const questionItems = (): QuestionItemsResult => {
|
const questionItems = (): QuestionItemsResult => {
|
||||||
let result: QuestionItemsResult = {
|
const part = currentSection.state as ReadingPart | ListeningPart | LevelPart;
|
||||||
ids: [],
|
const items = getExerciseItems(part.exercises, sectionId);
|
||||||
items: []
|
return {
|
||||||
};
|
items,
|
||||||
|
ids: items.map(item => item.id)
|
||||||
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;
|
|
||||||
}
|
|
||||||
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) => {
|
const background = (component: ReactNode) => {
|
||||||
@@ -108,33 +180,33 @@ 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} />);
|
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} />);
|
||||||
|
|
||||||
const questions = questionItems();
|
const questions = questionItems();
|
||||||
|
|
||||||
|
// #############################################################################
|
||||||
|
// Typescript checks so that the compiler and builder don't freak out
|
||||||
const filteredIds = (questions.ids ?? []).filter(Boolean);
|
const filteredIds = (questions.ids ?? []).filter(Boolean);
|
||||||
|
|
||||||
function isValidItem(item: ExerciseItem | undefined): item is ExerciseItem {
|
function isValidItem(item: ExerciseItem | undefined): item is ExerciseItem {
|
||||||
return item !== undefined &&
|
return item !== undefined &&
|
||||||
typeof item.id === 'string' &&
|
typeof item.id === 'string' &&
|
||||||
typeof item.sectionId === 'number' &&
|
typeof item.sectionId === 'number' &&
|
||||||
React.isValidElement(item.label) &&
|
React.isValidElement(item.label) &&
|
||||||
React.isValidElement(item.content);
|
React.isValidElement(item.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredItems = (questions.items ?? []).filter(isValidItem);
|
const filteredItems = (questions.items ?? []).filter(isValidItem);
|
||||||
|
// #############################################################################
|
||||||
|
|
||||||
|
console.log(levelGenerating);
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
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) ? (
|
{expandedSections.includes(sectionId) &&
|
||||||
background(<span className="flex justify-center">Generated exercises will appear here!</span>)
|
|
||||||
) : (
|
|
||||||
expandedSections.includes(sectionId) &&
|
|
||||||
questions.items &&
|
questions.items &&
|
||||||
questions.items.length > 0 &&
|
questions.items.length > 0 &&
|
||||||
questions.ids &&
|
questions.ids &&
|
||||||
@@ -160,9 +232,16 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
}
|
||||||
|
|
||||||
{generating === "exercises" && <GenLoader module={currentModule} className="mt-4" />}
|
{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 >
|
</DndContext >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
content: <FillBlanksMC exercise={exercise} sectionId={sectionId} />
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {} as unknown as ExerciseItem;
|
|
||||||
}
|
|
||||||
}).filter(isExerciseItem);
|
|
||||||
|
|
||||||
return items;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default getLevelQuestionItems;
|
|
||||||
@@ -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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
content: <WriteBlanksForm exercise={exercise} sectionId={sectionId} />
|
|
||||||
};
|
|
||||||
case 'fill':
|
|
||||||
return {
|
|
||||||
id: index.toString(),
|
|
||||||
sectionId,
|
|
||||||
label: (
|
|
||||||
<ExerciseLabel
|
|
||||||
label={`Write Blanks: Fill #${firstWordId} - #${lastWordId}`}
|
|
||||||
preview={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
|
|
||||||
};
|
|
||||||
case 'questions':
|
|
||||||
return {
|
|
||||||
id: index.toString(),
|
|
||||||
sectionId,
|
|
||||||
label: (
|
|
||||||
<ExerciseLabel
|
|
||||||
label={`Write Blanks: Questions #${firstWordId} ${firstWordId === lastWordId ? '' : `- #${lastWordId}`}`}
|
|
||||||
preview={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return mappedItems.filter((item): item is ExerciseItem =>
|
|
||||||
item !== null && isExerciseItem(item)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default getListeningItems;
|
|
||||||
@@ -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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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={
|
|
||||||
<>
|
|
||||||
"{previewLabel(exercise.prompt)}..."
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
}).filter(isExerciseItem);
|
|
||||||
|
|
||||||
return items;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default getExerciseItems;
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
|
||||||
|
|
||||||
export default interface ExerciseItem {
|
export default interface ExerciseItem {
|
||||||
id: string;
|
id: string;
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
@@ -7,8 +5,6 @@ export default interface ExerciseItem {
|
|||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise | MultipleChoiceExercise;
|
|
||||||
|
|
||||||
export function isExerciseItem(item: unknown): item is ExerciseItem {
|
export function isExerciseItem(item: unknown): item is ExerciseItem {
|
||||||
return item !== undefined &&
|
return item !== undefined &&
|
||||||
item !== null &&
|
item !== null &&
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -7,6 +7,8 @@ import useExamEditorStore from '@/stores/examEditor';
|
|||||||
import { ModuleState } from '@/stores/examEditor/types';
|
import { ModuleState } from '@/stores/examEditor/types';
|
||||||
import ListeningContext from './SectionContext/listening';
|
import ListeningContext from './SectionContext/listening';
|
||||||
import SectionDropdown from '../Shared/SectionDropdown';
|
import SectionDropdown from '../Shared/SectionDropdown';
|
||||||
|
import LevelContext from './SectionContext/level';
|
||||||
|
import { Module } from '@/interfaces';
|
||||||
|
|
||||||
|
|
||||||
const SectionRenderer: React.FC = () => {
|
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,
|
reading: ReadingContext,
|
||||||
listening: ListeningContext,
|
listening: ListeningContext,
|
||||||
|
level: LevelContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SectionContext = ContextMap[currentModule];
|
const SectionContext = ContextMap[currentModule];
|
||||||
@@ -83,7 +86,7 @@ const SectionRenderer: React.FC = () => {
|
|||||||
onFocus={() => updateModule({ focusedSection: id })}
|
onFocus={() => updateModule({ focusedSection: id })}
|
||||||
tabIndex={id + 1}
|
tabIndex={id + 1}
|
||||||
>
|
>
|
||||||
{currentModule in ContextMap && <SectionContext sectionId={id} />}
|
{currentModule in ContextMap && <SectionContext sectionId={id} module={currentModule} />}
|
||||||
<SectionExercises sectionId={id} />
|
<SectionExercises sectionId={id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import { GeneratedExercises, GeneratorState } from "../Shared/ExercisePicker/generatedExercises";
|
import { GeneratedExercises, GeneratorState } from "../ExercisePicker/generatedExercises";
|
||||||
import { SectionState } from "@/stores/examEditor/types";
|
import { SectionState } from "@/stores/examEditor/types";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,27 +14,51 @@ interface GeneratorConfig {
|
|||||||
export function generate(
|
export function generate(
|
||||||
sectionId: number,
|
sectionId: number,
|
||||||
module: Module,
|
module: Module,
|
||||||
type: "context" | "exercises",
|
type: Generating,
|
||||||
config: GeneratorConfig,
|
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({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
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({
|
dispatch({
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
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
|
const queryString = config.queryParams
|
||||||
? new URLSearchParams(config.queryParams).toString()
|
? new URLSearchParams(config.queryParams).toString()
|
||||||
@@ -49,13 +73,11 @@ export function generate(
|
|||||||
request
|
request
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
playSound("check");
|
playSound("check");
|
||||||
setGeneratedExercises(sectionId, mapData(result.data));
|
setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
setGenerating(sectionId, undefined, level, true);
|
||||||
playSound("error");
|
playSound("error");
|
||||||
toast.error("Something went wrong! Try to generate again.");
|
toast.error("Something went wrong! Try to generate again.");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
|
||||||
setGenerating(sectionId, undefined);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Module } from "@/interfaces";
|
|||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { Generating } from "@/stores/examEditor/types";
|
import { Generating } from "@/stores/examEditor/types";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { BsArrowRepeat } from "react-icons/bs";
|
import { BsArrowRepeat } from "react-icons/bs";
|
||||||
import { GiBrain } from "react-icons/gi";
|
import { GiBrain } from "react-icons/gi";
|
||||||
|
|
||||||
@@ -11,25 +12,37 @@ interface Props {
|
|||||||
genType: Generating;
|
genType: Generating;
|
||||||
generateFnc: (sectionId: number) => void
|
generateFnc: (sectionId: number) => void
|
||||||
className?: string;
|
className?: string;
|
||||||
|
levelId?: number;
|
||||||
|
level?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => {
|
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, levelId }) => {
|
||||||
const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId));
|
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 <></>;
|
if (section === undefined) return <></>;
|
||||||
|
|
||||||
const {generating} = section;
|
|
||||||
|
|
||||||
|
|
||||||
const loading = generating && generating === genType;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`section-${sectionId}`}
|
key={`section-${sectionId}`}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg",
|
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
|
||||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module}`,
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={loading ? () => { } : () => generateFnc(sectionId)}
|
disabled={loading}
|
||||||
|
onClick={loading ? () => { } : () => generateFnc(levelId ? levelId : sectionId)}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
||||||
@@ -43,6 +56,6 @@ const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc,
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GenerateBtn;
|
export default GenerateBtn;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -10,9 +10,10 @@ interface Props {
|
|||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
center?: boolean;
|
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 (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
title={title}
|
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`,
|
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||||
open ? "rounded-t-lg" : "rounded-lg"
|
open ? "rounded-t-lg" : "rounded-lg"
|
||||||
)}
|
)}
|
||||||
contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${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}
|
open={open}
|
||||||
setIsOpen={setIsOpen}
|
setIsOpen={setIsOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { Exercise, LevelExam, LevelPart } from "@/interfaces/exam";
|
import { Exercise, InteractiveSpeakingExercise, LevelExam, LevelPart, SpeakingExercise } from "@/interfaces/exam";
|
||||||
import SettingsEditor from ".";
|
import SettingsEditor from ".";
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import Dropdown from "@/components/Dropdown";
|
import Dropdown from "@/components/Dropdown";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import ExercisePicker from "../Shared/ExercisePicker";
|
import ExercisePicker from "../ExercisePicker";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import useSettingsState from "../Hooks/useSettingsState";
|
import useSettingsState from "../Hooks/useSettingsState";
|
||||||
import { SectionSettings } from "@/stores/examEditor/types";
|
import { LevelSectionSettings, SectionSettings } from "@/stores/examEditor/types";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/examStore";
|
||||||
import openDetachedTab from "@/utils/popout";
|
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 = () => {
|
const LevelSettings: React.FC = () => {
|
||||||
@@ -26,8 +32,8 @@ const LevelSettings: React.FC = () => {
|
|||||||
setQuestionIndex,
|
setQuestionIndex,
|
||||||
setBgColor,
|
setBgColor,
|
||||||
} = usePersistentExamStore();
|
} = usePersistentExamStore();
|
||||||
|
|
||||||
const {currentModule, title } = useExamEditorStore();
|
const { currentModule, title } = useExamEditorStore();
|
||||||
const {
|
const {
|
||||||
focusedSection,
|
focusedSection,
|
||||||
difficulty,
|
difficulty,
|
||||||
@@ -36,15 +42,18 @@ const LevelSettings: React.FC = () => {
|
|||||||
isPrivate,
|
isPrivate,
|
||||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||||
|
|
||||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
|
||||||
currentModule,
|
currentModule,
|
||||||
focusedSection
|
focusedSection
|
||||||
);
|
);
|
||||||
|
|
||||||
const section = sections.find((section) => section.sectionId == focusedSection);
|
const section = sections.find((section) => section.sectionId == focusedSection);
|
||||||
|
const focusedExercise = section?.focusedExercise;
|
||||||
if (section === undefined) return <></>;
|
if (section === undefined) return <></>;
|
||||||
|
|
||||||
const currentSection = section.state as LevelPart;
|
const currentSection = section.state as LevelPart;
|
||||||
|
const readingSection = section.readingSection;
|
||||||
|
const listeningSection = section.listeningSection;
|
||||||
|
|
||||||
const canPreview = currentSection.exercises.length > 0;
|
const canPreview = currentSection.exercises.length > 0;
|
||||||
|
|
||||||
@@ -105,6 +114,8 @@ const LevelSettings: React.FC = () => {
|
|||||||
openDetachedTab("popout?type=Exam&module=level", router)
|
openDetachedTab("popout?type=Exam&module=level", router)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises[focusedExercise] as SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsEditor
|
<SettingsEditor
|
||||||
sectionLabel={`Part ${focusedSection}`}
|
sectionLabel={`Part ${focusedSection}`}
|
||||||
@@ -117,26 +128,149 @@ const LevelSettings: React.FC = () => {
|
|||||||
submitModule={submitLevel}
|
submitModule={submitLevel}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Dropdown title="Add Exercises" className={
|
<Dropdown title="Add Level Exercises" className={
|
||||||
clsx(
|
clsx(
|
||||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
"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",
|
"bg-ielts-level/70 border-ielts-level hover:bg-ielts-level",
|
||||||
"text-white shadow-md transition-all duration-300",
|
"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"}
|
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
|
||||||
open={localSettings.isExerciseDropdownOpen}
|
open={localSettings.isLevelDropdownOpen}
|
||||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isLevelDropdownOpen: isOpen }, false)}
|
||||||
>
|
>
|
||||||
<ExercisePicker
|
<ExercisePicker
|
||||||
module="level"
|
module="level"
|
||||||
sectionId={focusedSection}
|
sectionId={focusedSection}
|
||||||
difficulty={difficulty}
|
difficulty={difficulty}
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</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 >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import Dropdown from "./Shared/SettingsDropdown";
|
import Dropdown from "../Shared/SettingsDropdown";
|
||||||
import ExercisePicker from "../Shared/ExercisePicker";
|
import ExercisePicker from "../../ExercisePicker";
|
||||||
import SettingsEditor from ".";
|
import SettingsEditor from "..";
|
||||||
import GenerateBtn from "./Shared/GenerateBtn";
|
import GenerateBtn from "../Shared/GenerateBtn";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { generate } from "./Shared/Generate";
|
import { generate } from "../Shared/Generate";
|
||||||
import { Generating, ListeningSectionSettings } from "@/stores/examEditor/types";
|
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import useSettingsState from "../Hooks/useSettingsState";
|
import useSettingsState from "../../Hooks/useSettingsState";
|
||||||
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import openDetachedTab from "@/utils/popout";
|
import openDetachedTab from "@/utils/popout";
|
||||||
@@ -16,10 +16,11 @@ import axios from "axios";
|
|||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/examStore";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import ListeningComponents from "./components";
|
||||||
|
|
||||||
const ListeningSettings: React.FC = () => {
|
const ListeningSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { currentModule, title, dispatch } = useExamEditorStore();
|
const { currentModule, title } = useExamEditorStore();
|
||||||
const {
|
const {
|
||||||
focusedSection,
|
focusedSection,
|
||||||
difficulty,
|
difficulty,
|
||||||
@@ -47,45 +48,21 @@ const ListeningSettings: React.FC = () => {
|
|||||||
{
|
{
|
||||||
label: "Preset: Listening Section 1",
|
label: "Preset: Listening Section 1",
|
||||||
value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions."
|
value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Preset: Listening Section 2",
|
label: "Preset: Listening Section 2",
|
||||||
value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements."
|
value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Preset: Listening Section 3",
|
label: "Preset: Listening Section 3",
|
||||||
value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements."
|
value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Preset: Listening Section 4",
|
label: "Preset: Listening Section 4",
|
||||||
value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject."
|
value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
const generateScript = useCallback(() => {
|
|
||||||
generate(
|
|
||||||
focusedSection,
|
|
||||||
currentModule,
|
|
||||||
"context",
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
queryParams: {
|
|
||||||
difficulty,
|
|
||||||
...(localSettings.topic && { topic: localSettings.topic })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(data: any) => [{
|
|
||||||
script: data.dialog
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [localSettings.topic, difficulty, focusedSection]);
|
|
||||||
|
|
||||||
const onTopicChange = useCallback((topic: string) => {
|
|
||||||
updateLocalAndScheduleGlobal({ topic });
|
|
||||||
}, [updateLocalAndScheduleGlobal]);
|
|
||||||
|
|
||||||
const submitListening = async () => {
|
const submitListening = async () => {
|
||||||
if (title === "") {
|
if (title === "") {
|
||||||
toast.error("Enter a title for the exam!");
|
toast.error("Enter a title for the exam!");
|
||||||
@@ -187,56 +164,6 @@ const ListeningSettings: React.FC = () => {
|
|||||||
openDetachedTab("popout?type=Exam&module=listening", router)
|
openDetachedTab("popout?type=Exam&module=listening", router)
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateAudio = useCallback(async (sectionId: number) => {
|
|
||||||
let body: any;
|
|
||||||
if ([1, 3].includes(sectionId)) {
|
|
||||||
body = { conversation: currentSection.script }
|
|
||||||
} else {
|
|
||||||
body = { monologue: currentSection.script }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
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(
|
const canPreview = sections.some(
|
||||||
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
||||||
@@ -259,65 +186,9 @@ const ListeningSettings: React.FC = () => {
|
|||||||
preview={preview}
|
preview={preview}
|
||||||
submitModule={submitListening}
|
submitModule={submitListening}
|
||||||
>
|
>
|
||||||
<Dropdown
|
<ListeningComponents
|
||||||
title="Audio Context"
|
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</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>
|
</SettingsEditor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
108
src/components/ExamEditor/SettingsEditor/reading/components.tsx
Normal file
108
src/components/ExamEditor/SettingsEditor/reading/components.tsx
Normal 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;
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import React, { useCallback, useState } from "react";
|
import React from "react";
|
||||||
import SettingsEditor from ".";
|
import SettingsEditor from "..";
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import Dropdown from "./Shared/SettingsDropdown";
|
import useSettingsState from "../../Hooks/useSettingsState";
|
||||||
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 { ReadingExam, ReadingPart } from "@/interfaces/exam";
|
import { ReadingExam, ReadingPart } from "@/interfaces/exam";
|
||||||
import { ReadingSectionSettings } from "@/stores/examEditor/types";
|
import { ReadingSectionSettings } from "@/stores/examEditor/types";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
@@ -16,6 +11,7 @@ import { usePersistentExamStore } from "@/stores/examStore";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import ReadingComponents from "./components";
|
||||||
|
|
||||||
const ReadingSettings: React.FC = () => {
|
const ReadingSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
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(
|
const canPreviewOrSubmit = sections.some(
|
||||||
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
||||||
);
|
);
|
||||||
@@ -161,49 +131,9 @@ const ReadingSettings: React.FC = () => {
|
|||||||
canSubmit={canPreviewOrSubmit}
|
canSubmit={canPreviewOrSubmit}
|
||||||
submitModule={submitReading}
|
submitModule={submitReading}
|
||||||
>
|
>
|
||||||
<Dropdown
|
<ReadingComponents
|
||||||
title="Generate Passage"
|
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</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>
|
</SettingsEditor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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;
|
|
||||||
268
src/components/ExamEditor/SettingsEditor/speaking/components.tsx
Normal file
268
src/components/ExamEditor/SettingsEditor/speaking/components.tsx
Normal 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;
|
||||||
261
src/components/ExamEditor/SettingsEditor/speaking/index.tsx
Normal file
261
src/components/ExamEditor/SettingsEditor/speaking/index.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import SettingsEditor from ".";
|
import SettingsEditor from "..";
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import Dropdown from "./Shared/SettingsDropdown";
|
import Dropdown from "../Shared/SettingsDropdown";
|
||||||
import Input from "@/components/Low/Input";
|
import Input from "@/components/Low/Input";
|
||||||
import { generate } from "./Shared/Generate";
|
import { generate } from "../Shared/Generate";
|
||||||
import useSettingsState from "../Hooks/useSettingsState";
|
import useSettingsState from "../../Hooks/useSettingsState";
|
||||||
import GenerateBtn from "./Shared/GenerateBtn";
|
import GenerateBtn from "../Shared/GenerateBtn";
|
||||||
import { SectionSettings } from "@/stores/examEditor/types";
|
import { WritingSectionSettings } from "@/stores/examEditor/types";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { usePersistentExamStore } from "@/stores/examStore";
|
import { usePersistentExamStore } from "@/stores/examStore";
|
||||||
@@ -16,6 +16,7 @@ import { v4 } from "uuid";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { playSound } from "@/utils/sound";
|
import { playSound } from "@/utils/sound";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import WritingComponents from "./components";
|
||||||
|
|
||||||
const WritingSettings: React.FC = () => {
|
const WritingSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -37,7 +38,7 @@ const WritingSettings: React.FC = () => {
|
|||||||
setExerciseIndex,
|
setExerciseIndex,
|
||||||
} = usePersistentExamStore();
|
} = usePersistentExamStore();
|
||||||
|
|
||||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<WritingSectionSettings>(
|
||||||
currentModule,
|
currentModule,
|
||||||
focusedSection,
|
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(() => {
|
useEffect(() => {
|
||||||
setCanPreviewOrSubmit(states.some((s) => s.prompt !== ""))
|
setCanPreviewOrSubmit(states.some((s) => s.prompt !== ""))
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -145,36 +123,9 @@ const WritingSettings: React.FC = () => {
|
|||||||
canSubmit={canPreviewOrSubmit}
|
canSubmit={canPreviewOrSubmit}
|
||||||
submitModule={submitWriting}
|
submitModule={submitWriting}
|
||||||
>
|
>
|
||||||
<Dropdown
|
<WritingComponents
|
||||||
title="Generate Instructions"
|
{...{ localSettings, updateLocalAndScheduleGlobal }}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex self-end h-16 mb-1">
|
|
||||||
<GenerateBtn
|
|
||||||
genType="context"
|
|
||||||
module={currentModule}
|
|
||||||
sectionId={focusedSection}
|
|
||||||
generateFnc={generatePassage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dropdown>
|
|
||||||
</SettingsEditor>
|
</SettingsEditor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
label: string;
|
type: string;
|
||||||
preview?: React.ReactNode;
|
firstId: string;
|
||||||
|
lastId: string;
|
||||||
|
prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExerciseLabel: React.FC<Props> = ({label, preview}) => {
|
const previewLabel = (text: string) => {
|
||||||
|
return <>
|
||||||
|
"{text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""}..."
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (type: string, firstId: string, lastId: string) => {
|
||||||
|
return `${type} #${firstId} ${firstId === lastId ? '' : `- #${lastId}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full justify-between items-center mr-4">
|
<div className="flex w-full justify-between items-center mr-4">
|
||||||
<span className="font-semibold">{label}</span>
|
<span className="font-semibold">{label(type, firstId, lastId)}</span>
|
||||||
{preview && <div className="text-sm font-light italic">{preview}</div>}
|
<div className="text-sm font-light italic">{previewLabel(prompt)}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ReactNode } from "react";
|
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 {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -10,14 +11,15 @@ interface Props {
|
|||||||
module?: Module;
|
module?: Module;
|
||||||
handleSave: () => void;
|
handleSave: () => void;
|
||||||
handleDiscard: () => void;
|
handleDiscard: () => void;
|
||||||
modeHandle?: () => void;
|
handleDelete?: () => void;
|
||||||
evaluationHandle?: () => void;
|
handlePractice?: () => void;
|
||||||
|
handleEdit?: () => void;
|
||||||
isEvaluationEnabled?: boolean;
|
isEvaluationEnabled?: boolean;
|
||||||
mode?: "delete" | "edit";
|
|
||||||
children?: ReactNode;
|
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 (
|
return (
|
||||||
<div className="flex justify-between items-center mb-6 text-sm">
|
<div className="flex justify-between items-center mb-6 text-sm">
|
||||||
<div>
|
<div>
|
||||||
@@ -48,26 +50,18 @@ const Header: React.FC<Props> = ({ title, description, editing, isEvaluationEnab
|
|||||||
<MdRefresh size={18} />
|
<MdRefresh size={18} />
|
||||||
Discard
|
Discard
|
||||||
</button>
|
</button>
|
||||||
{mode === "delete" ? (
|
{handleEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={modeHandle}
|
onClick={handleEdit}
|
||||||
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}
|
|
||||||
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`}
|
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} />}
|
{editing ? <MdEditOff size={18} /> : <MdEdit size={18} />}
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{mode === "delete" &&
|
{handlePractice &&
|
||||||
<button
|
<button
|
||||||
onClick={evaluationHandle}
|
onClick={handlePractice}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
|
"px-4 py-2 rounded-lg flex items-center gap-2 transition-all duration-200",
|
||||||
isEvaluationEnabled
|
isEvaluationEnabled
|
||||||
@@ -75,10 +69,19 @@ const Header: React.FC<Props> = ({ title, description, editing, isEvaluationEnab
|
|||||||
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isEvaluationEnabled ? <MdGrade size={18} /> : <MdOutlineGrade size={18} />}
|
{isEvaluationEnabled ? <HiOutlineClipboardCheck size={18} /> : <HiOutlineClipboardList size={18} />}
|
||||||
{isEvaluationEnabled ? 'Graded Exercise' : 'Practice Only'}
|
{isEvaluationEnabled ? 'Graded' : 'Practice'}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import ReadingSettings from "./SettingsEditor/reading";
|
|||||||
import LevelSettings from "./SettingsEditor/level";
|
import LevelSettings from "./SettingsEditor/level";
|
||||||
import ListeningSettings from "./SettingsEditor/listening";
|
import ListeningSettings from "./SettingsEditor/listening";
|
||||||
import SpeakingSettings from "./SettingsEditor/speaking";
|
import SpeakingSettings from "./SettingsEditor/speaking";
|
||||||
import ImportOrStartFromScratch from "./Shared/ImportExam/ImportOrFromScratch";
|
import ImportOrStartFromScratch from "./ImportExam/ImportOrFromScratch";
|
||||||
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
import { defaultSectionSettings } from "@/stores/examEditor/defaults";
|
||||||
|
|
||||||
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ export default function Listening({ exam, showSolutions = false, preview = false
|
|||||||
minTimer={exam.minTimer}
|
minTimer={exam.minTimer}
|
||||||
module="listening"
|
module="listening"
|
||||||
totalExercises={exam.parts.length}
|
totalExercises={exam.parts.length}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions || preview}
|
||||||
indexLabel="Part"
|
indexLabel="Part"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
|||||||
(x) => x === 0,
|
(x) => x === 0,
|
||||||
) &&
|
) &&
|
||||||
!showSolutions &&
|
!showSolutions &&
|
||||||
|
!preview &&
|
||||||
!hasExamEnded
|
!hasExamEnded
|
||||||
) {
|
) {
|
||||||
setShowBlankModal(true);
|
setShowBlankModal(true);
|
||||||
@@ -324,7 +325,7 @@ export default function Reading({ exam, showSolutions = false, preview = false,
|
|||||||
exerciseIndex={calculateExerciseIndex()}
|
exerciseIndex={calculateExerciseIndex()}
|
||||||
module="reading"
|
module="reading"
|
||||||
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
||||||
disableTimer={showSolutions}
|
disableTimer={showSolutions || preview}
|
||||||
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ export interface LevelExam extends ExamBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelPart extends Section {
|
export interface LevelPart extends Section {
|
||||||
|
// to support old exams that have reading passage mc on context
|
||||||
context?: string;
|
context?: string;
|
||||||
exercises: Exercise[];
|
exercises: Exercise[];
|
||||||
audio?: {
|
audio?: {
|
||||||
source: string;
|
source: string;
|
||||||
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
|
||||||
};
|
};
|
||||||
|
script?: Script;
|
||||||
text?: {
|
text?: {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -163,6 +165,7 @@ export interface WritingExercise extends Section {
|
|||||||
evaluation?: WritingEvaluation;
|
evaluation?: WritingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
topic?: string;
|
topic?: string;
|
||||||
|
variant?: string;
|
||||||
isPractice?: boolean
|
isPractice?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const queryParams = queryToURLSearchParams(req);
|
const queryParams = queryToURLSearchParams(req);
|
||||||
let endpoint = queryParams.getAll('module').join("/");
|
let endpoint = queryParams.getAll('module').join("/");
|
||||||
|
|
||||||
if (endpoint.startsWith("level")) {
|
if (endpoint.startsWith("level")) {
|
||||||
endpoint = "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}`,
|
const result = await axios.post(`${process.env.BACKEND_URL}/${endpoint}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Module } from "@/interfaces"
|
import { Module } from "@/interfaces"
|
||||||
import { Difficulty} from "@/interfaces/exam"
|
import { Difficulty } from "@/interfaces/exam"
|
||||||
import { sample } from "lodash"
|
import { sample } from "lodash"
|
||||||
import { ExamPart, ModuleState, SectionState } from "./types"
|
import { ExamPart, ModuleState, SectionState } from "./types"
|
||||||
import { levelPart, listeningSection, readingPart, speakingTask, writingTask } from "@/stores/examEditor/sections"
|
import { levelPart, listeningSection, readingPart, speakingTask, writingTask } from "@/stores/examEditor/sections"
|
||||||
@@ -18,21 +18,59 @@ const defaultSettings = (module: Module) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (module) {
|
switch (module) {
|
||||||
|
case 'writing':
|
||||||
|
return {
|
||||||
|
...baseSettings,
|
||||||
|
writingTopic: '',
|
||||||
|
isWritingTopicOpen: false
|
||||||
|
}
|
||||||
case 'reading':
|
case 'reading':
|
||||||
return {
|
return {
|
||||||
...baseSettings,
|
...baseSettings,
|
||||||
isPassageOpen: false,
|
isPassageOpen: false,
|
||||||
|
readingTopic: '',
|
||||||
|
isReadingTopicOpean: false,
|
||||||
}
|
}
|
||||||
case 'listening':
|
case 'listening':
|
||||||
return {
|
return {
|
||||||
...baseSettings,
|
...baseSettings,
|
||||||
isAudioContextOpen: false,
|
isAudioContextOpen: false,
|
||||||
isAudioGenerationOpen: false,
|
isAudioGenerationOpen: false,
|
||||||
|
listeningTopic: '',
|
||||||
|
isListeningTopicOpen: false,
|
||||||
}
|
}
|
||||||
case 'speaking':
|
case 'speaking':
|
||||||
return {
|
return {
|
||||||
...baseSettings,
|
...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:
|
default:
|
||||||
return baseSettings;
|
return baseSettings;
|
||||||
@@ -47,16 +85,16 @@ const sectionLabels = (module: Module) => {
|
|||||||
label: `Passage ${index + 1}`
|
label: `Passage ${index + 1}`
|
||||||
}));
|
}));
|
||||||
case 'writing':
|
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':
|
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':
|
case 'listening':
|
||||||
return Array.from({ length: 4 }, (_, index) => ({
|
return Array.from({ length: 4 }, (_, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
label: `Section ${index + 1}`
|
label: `Section ${index + 1}`
|
||||||
}));
|
}));
|
||||||
case 'level':
|
case 'level':
|
||||||
return [{id: 1, label: "Part 1"}];
|
return [{ id: 1, label: "Part 1" }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +123,7 @@ const defaultSection = (module: Module, sectionId: number) => {
|
|||||||
return listeningSection(sectionId)
|
return listeningSection(sectionId)
|
||||||
case 'speaking':
|
case 'speaking':
|
||||||
return speakingTask(sectionId)
|
return speakingTask(sectionId)
|
||||||
case 'level':
|
case 'level':
|
||||||
return levelPart(sectionId)
|
return levelPart(sectionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,8 +137,8 @@ export const defaultSectionSettings = (module: Module, sectionId: number, part?:
|
|||||||
generating: undefined,
|
generating: undefined,
|
||||||
genResult: undefined,
|
genResult: undefined,
|
||||||
expandedSubSections: [],
|
expandedSubSections: [],
|
||||||
exercisePickerState: [],
|
levelGenerating: [],
|
||||||
selectedExercises: [],
|
levelGenResults: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { reorderSection } from "../reorder/global";
|
|||||||
|
|
||||||
export type SectionActions =
|
export type SectionActions =
|
||||||
| { type: 'UPDATE_SECTION_SINGLE_FIELD'; payload: { module: Module; sectionId: number; field: string; value: any } }
|
| { 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_SETTINGS'; payload: { sectionId: number; module: Module; update: Partial<SectionSettings | ReadingSectionSettings>; } }
|
||||||
| { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; update: Partial<Section>; } }
|
| { type: 'UPDATE_SECTION_STATE'; payload: { sectionId: number; module: Module; update: Partial<Section>; } }
|
||||||
| { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, sectionId: number; } };
|
| { type: 'REORDER_EXERCISES'; payload: { event: DragEndEvent, module: Module; sectionId: number; } };
|
||||||
|
|
||||||
export const SECTION_ACTIONS = [
|
export const SECTION_ACTIONS = [
|
||||||
'UPDATE_SECTION_SETTINGS',
|
'UPDATE_SECTION_SETTINGS',
|
||||||
@@ -20,24 +20,15 @@ export const sectionReducer = (
|
|||||||
state: ExamEditorStore,
|
state: ExamEditorStore,
|
||||||
action: SectionActions
|
action: SectionActions
|
||||||
): Partial<ExamEditorStore> => {
|
): 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) {
|
switch (action.type) {
|
||||||
case 'UPDATE_SECTION_SINGLE_FIELD':
|
case 'UPDATE_SECTION_SINGLE_FIELD':{
|
||||||
const { module, field, value } = action.payload;
|
const { module, field, value, sectionId } = action.payload;
|
||||||
return {
|
return {
|
||||||
modules: {
|
modules: {
|
||||||
...modules,
|
...state.modules,
|
||||||
[module]: {
|
[module]: {
|
||||||
...modules[module],
|
...state.modules[module],
|
||||||
sections: sections.map((section: SectionState) =>
|
sections: state.modules[module].sections.map((section: SectionState) =>
|
||||||
section.sectionId === sectionId
|
section.sectionId === sectionId
|
||||||
? {
|
? {
|
||||||
...section,
|
...section,
|
||||||
@@ -48,22 +39,21 @@ export const sectionReducer = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
case 'UPDATE_SECTION_SETTINGS':{
|
||||||
case 'UPDATE_SECTION_SETTINGS':
|
const {module, sectionId, update} = action.payload;
|
||||||
let updatedSettings = action.payload.update;
|
|
||||||
return {
|
return {
|
||||||
modules: {
|
modules: {
|
||||||
...modules,
|
...state.modules,
|
||||||
[currentModule]: {
|
[module]: {
|
||||||
...modules[currentModule],
|
...state.modules[module],
|
||||||
sections: sections.map((section: SectionState) =>
|
sections: state.modules[module].sections.map((section: SectionState) =>
|
||||||
section.sectionId === sectionId
|
section.sectionId === sectionId
|
||||||
? {
|
? {
|
||||||
...section,
|
...section,
|
||||||
settings: {
|
settings: {
|
||||||
...section.settings,
|
...section.settings,
|
||||||
...updatedSettings
|
...update
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: section
|
: section
|
||||||
@@ -71,30 +61,32 @@ export const sectionReducer = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'UPDATE_SECTION_STATE':
|
case 'UPDATE_SECTION_STATE': {
|
||||||
const updatedState = action.payload.update;
|
const { update, module, sectionId }= action.payload;
|
||||||
return {
|
return {
|
||||||
modules: {
|
modules: {
|
||||||
...modules,
|
...state.modules,
|
||||||
[currentModule]: {
|
[module]: {
|
||||||
...modules[currentModule],
|
...state.modules[module],
|
||||||
sections: sections.map(section =>
|
sections: state.modules[module].sections.map(section =>
|
||||||
section.sectionId === sectionId
|
section.sectionId === sectionId
|
||||||
? { ...section, state: { ...section.state, ...updatedState } }
|
? { ...section, state: { ...section.state, ...update } }
|
||||||
: section
|
: section
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'REORDER_EXERCISES': {
|
case 'REORDER_EXERCISES': {
|
||||||
const { active, over } = action.payload.event;
|
const { event, sectionId, module } = action.payload;
|
||||||
|
const {over, active} = event;
|
||||||
if (!over) return state;
|
if (!over) return state;
|
||||||
|
|
||||||
const oldIndex = active.id as number;
|
const oldIndex = active.id as number;
|
||||||
const newIndex = over.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 exercises = [...currentSectionState.exercises];
|
||||||
const [removed] = exercises.splice(oldIndex, 1);
|
const [removed] = exercises.splice(oldIndex, 1);
|
||||||
exercises.splice(newIndex, 0, removed);
|
exercises.splice(newIndex, 0, removed);
|
||||||
@@ -110,9 +102,9 @@ export const sectionReducer = (
|
|||||||
...state,
|
...state,
|
||||||
modules: {
|
modules: {
|
||||||
...state.modules,
|
...state.modules,
|
||||||
[currentModule]: {
|
[module]: {
|
||||||
...modules[currentModule],
|
...state.modules[module],
|
||||||
sections: sections.map(section =>
|
sections: state.modules[module].sections.map(section =>
|
||||||
section.sectionId === sectionId
|
section.sectionId === sectionId
|
||||||
? { ...section, state: newSectionState }
|
? { ...section, state: newSectionState }
|
||||||
: section
|
: section
|
||||||
|
|||||||
@@ -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 { ModuleState } from "../types";
|
||||||
import ReorderResult from "./types";
|
import ReorderResult from "./types";
|
||||||
|
|
||||||
@@ -143,7 +143,6 @@ const reorderSection = (exercises: Exercise[], startId: number): { exercises: Ex
|
|||||||
|
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case 'fillBlanks':
|
case 'fillBlanks':
|
||||||
console.log("Reordering FillBlanks");
|
|
||||||
result = reorderFillBlanks(exercise, currentId);
|
result = reorderFillBlanks(exercise, currentId);
|
||||||
currentId = result.lastId;
|
currentId = result.lastId;
|
||||||
return result.exercise;
|
return result.exercise;
|
||||||
@@ -168,6 +167,10 @@ const reorderSection = (exercises: Exercise[], startId: number): { exercises: Ex
|
|||||||
currentId = result.lastId;
|
currentId = result.lastId;
|
||||||
return result.exercise;
|
return result.exercise;
|
||||||
|
|
||||||
|
case 'writing':
|
||||||
|
exercise = { ...exercise, sectionId: currentId };
|
||||||
|
currentId += 1;
|
||||||
|
return exercise;
|
||||||
default:
|
default:
|
||||||
return exercise;
|
return exercise;
|
||||||
}
|
}
|
||||||
@@ -184,7 +187,6 @@ const reorderModule = (moduleState: ModuleState) => {
|
|||||||
let currentId = 1;
|
let currentId = 1;
|
||||||
let reorderedSections = moduleState.sections.map(section => {
|
let reorderedSections = moduleState.sections.map(section => {
|
||||||
let currentSection = section.state as ReadingPart | ListeningPart | LevelPart;
|
let currentSection = section.state as ReadingPart | ListeningPart | LevelPart;
|
||||||
console.log(currentSection.exercises);
|
|
||||||
let result = reorderSection(currentSection.exercises, currentId);
|
let result = reorderSection(currentSection.exercises, currentId);
|
||||||
currentId = result.lastId;
|
currentId = result.lastId;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export const writingTask = (task: number) => ({
|
|||||||
wordCounter: {
|
wordCounter: {
|
||||||
limit: task == 1 ? 150 : 250,
|
limit: task == 1 ? 150 : 250,
|
||||||
type: "min",
|
type: "min",
|
||||||
}
|
},
|
||||||
|
order: 1
|
||||||
} as WritingExercise);
|
} as WritingExercise);
|
||||||
|
|
||||||
export const readingPart = (task: number) => {
|
export const readingPart = (task: number) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import Option from "@/interfaces/option";
|
import Option from "@/interfaces/option";
|
||||||
import { ExerciseConfig } from "@/components/ExamEditor/Shared/ExercisePicker/ExerciseWizard";
|
import { ExerciseConfig } from "@/components/ExamEditor/ExercisePicker/ExerciseWizard";
|
||||||
|
|
||||||
export interface GeneratedExercises {
|
export interface GeneratedExercises {
|
||||||
exercises: Record<string, string>[];
|
exercises: Record<string, string>[];
|
||||||
@@ -16,32 +16,70 @@ export interface SectionSettings {
|
|||||||
currentIntro: string | undefined;
|
currentIntro: string | undefined;
|
||||||
isCategoryDropdownOpen: boolean;
|
isCategoryDropdownOpen: boolean;
|
||||||
isIntroDropdownOpen: boolean;
|
isIntroDropdownOpen: boolean;
|
||||||
isExerciseDropdownOpen: boolean;
|
|
||||||
topic?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeakingSectionSettings extends SectionSettings {
|
export interface SpeakingSectionSettings extends SectionSettings {
|
||||||
secondTopic?: string;
|
speakingTopic: string;
|
||||||
isGenerateAudioOpen: boolean;
|
speakingSecondTopic?: string;
|
||||||
|
isSpeakingTopicOpen: boolean;
|
||||||
|
isGenerateVideoOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingSectionSettings extends SectionSettings {
|
export interface ReadingSectionSettings extends SectionSettings {
|
||||||
isPassageOpen: boolean;
|
isPassageOpen: boolean;
|
||||||
|
readingTopic: string;
|
||||||
|
isReadingTopicOpean: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListeningSectionSettings extends SectionSettings {
|
export interface ListeningSectionSettings extends SectionSettings {
|
||||||
isAudioContextOpen: boolean;
|
isAudioContextOpen: boolean;
|
||||||
isAudioGenerationOpen: boolean;
|
isAudioGenerationOpen: boolean;
|
||||||
|
listeningTopic: string;
|
||||||
|
isListeningTopicOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingSectionSettings extends SectionSettings {
|
||||||
|
isWritingTopicOpen: boolean;
|
||||||
|
writingTopic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelSectionSettings extends SectionSettings {
|
export interface LevelSectionSettings extends SectionSettings {
|
||||||
readingDropdownOpen: boolean;
|
isReadingDropdownOpen: boolean;
|
||||||
writingDropdownOpen: boolean;
|
isWritingDropdownOpen: boolean;
|
||||||
speakingDropdownOpen: boolean;
|
isSpeakingDropdownOpen: boolean;
|
||||||
listeningDropdownOpen: 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 Section = LevelPart | ReadingPart | ListeningPart | WritingExercise | SpeakingExercise | InteractiveSpeakingExercise;
|
||||||
export type ExamPart = ListeningPart | ReadingPart | LevelPart;
|
export type ExamPart = ListeningPart | ReadingPart | LevelPart;
|
||||||
|
|
||||||
@@ -51,9 +89,14 @@ export interface SectionState {
|
|||||||
state: Section;
|
state: Section;
|
||||||
expandedSubSections: number[];
|
expandedSubSections: number[];
|
||||||
generating: Generating;
|
generating: Generating;
|
||||||
genResult: Record<string, any>[] | undefined;
|
genResult: {generating: string, result: Record<string, any>[], module: Module} | undefined;
|
||||||
exercisePickerState: ExerciseConfig[];
|
levelGenerating: Generating[];
|
||||||
selectedExercises: string[];
|
levelGenResults: {generating: string, result: Record<string, any>[], module: Module}[];
|
||||||
|
focusedExercise?: number;
|
||||||
|
writingSection?: number;
|
||||||
|
speakingSection?: number;
|
||||||
|
readingSection?: number;
|
||||||
|
listeningSection?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleState {
|
export interface ModuleState {
|
||||||
|
|||||||
Reference in New Issue
Block a user