Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop

This commit is contained in:
Tiago Ribeiro
2024-11-13 09:13:04 +00:00
64 changed files with 2639 additions and 1651 deletions

View File

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

View File

@@ -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"
@@ -339,7 +351,19 @@ const EXERCISES: ExerciseGen[] = [
type: "speaking_1", type: "speaking_1",
icon: FaComments, icon: FaComments,
extra: [ extra: [
generate() generate(),
{
label: "First Topic",
param: "first_topic",
value: "",
type: "text"
},
{
label: "Second Topic",
param: "second_topic",
value: "",
type: "text"
},
], ],
module: "speaking" module: "speaking"
}, },
@@ -348,7 +372,13 @@ const EXERCISES: ExerciseGen[] = [
type: "speaking_2", type: "speaking_2",
icon: FaUserFriends, icon: FaUserFriends,
extra: [ extra: [
generate() generate(),
{
label: "Topic",
param: "topic",
value: "",
type: "text"
},
], ],
module: "speaking" module: "speaking"
}, },
@@ -357,7 +387,13 @@ const EXERCISES: ExerciseGen[] = [
type: "speaking_3", type: "speaking_3",
icon: FaHandshake, icon: FaHandshake,
extra: [ extra: [
generate() generate(),
{
label: "Topic",
param: "topic",
value: "",
type: "text"
},
], ],
module: "speaking" module: "speaking"
}, },

View File

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

View File

@@ -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,24 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
} }
}); });
@@ -231,8 +243,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 +262,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">

View File

@@ -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,24 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
} }
}); });
@@ -189,7 +201,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
useEffect(() => { useEffect(() => {
validateBlanks(blanksState.blanks, answers, alerts, setAlerts); validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
// 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(() => {
@@ -215,7 +227,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }
solution solution
]) ])
)); ));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleBlankRemove = (blankId: number) => { const handleBlankRemove = (blankId: number) => {
@@ -251,9 +263,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">

View File

@@ -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,31 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
} }
}); });
@@ -160,8 +171,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>

View File

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

View File

@@ -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,31 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
} }
}); });
@@ -142,8 +154,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 +224,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"

View File

@@ -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,32 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}); });
return ( return (
@@ -108,8 +119,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} />}

View File

@@ -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,32 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}); });
useEffect(() => { useEffect(() => {
@@ -197,8 +208,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">

View File

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

View File

@@ -12,53 +12,71 @@ 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
};
setLocal((prev) => ({...prev, 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 +109,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]);
@@ -134,14 +143,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 +170,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 +183,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 +206,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>

View File

@@ -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,38 @@ const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
] ]
}); });
}, },
onMode: () => { }, onPractice: () => {
const updatedExercise = {
...state,
isPractice: !local.isPractice
};
setLocal((prev) => ({...prev, 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 +129,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 +145,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 +172,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 +323,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">

View File

@@ -12,52 +12,70 @@ 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
};
setLocal((prev) => ({...prev, 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 +84,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 +147,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 +270,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">

View File

@@ -1,30 +1,32 @@
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";
import { Module } from "@/interfaces";
interface Props { interface Props {
sectionId: number; sectionId: number;
exercise: SpeakingExercise | InteractiveSpeakingExercise; exercise: SpeakingExercise | InteractiveSpeakingExercise;
qId?: number;
module: Module;
} }
const Speaking: React.FC<Props> = ({ sectionId, exercise }) => { const Speaking: React.FC<Props> = ({ sectionId, exercise, qId, module = "speaking" }) => {
const { currentModule } = useExamEditorStore(); const { dispatch } = useExamEditorStore();
const { state } = useExamEditorStore( const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)! (state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
); );
const onFocus = () => {
if (qId) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module, sectionId, field: "focusedExercise", value: { questionId: qId, id: exercise.id } } })
}
}
return ( return (
<> <>
<div className="mx-auto p-3 space-y-6"> <div tabIndex={0} className="mx-auto p-3 space-y-6" onFocus={onFocus}>
<div className="p-4"> <div className="p-4">
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
{sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />} {sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}

View File

@@ -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,30 @@ 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: false
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
updateLocal({...local, isPractice: !local.isPractice})
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}); });
useEffect(() => { useEffect(() => {
@@ -127,8 +138,10 @@ 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}
isEvaluationEnabled={!local.isPractice}
/> />
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />} {alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit <PromptEdit

View File

@@ -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,30 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}); });
useEffect(() => { useEffect(() => {
@@ -222,12 +233,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} />}

View File

@@ -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,31 @@ 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
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
}); });
useEffect(() => { useEffect(() => {
@@ -204,7 +215,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} />}

View File

@@ -2,22 +2,24 @@ 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)!
); );
@@ -27,42 +29,61 @@ const Writing: React.FC<Props> = ({ sectionId, exercise }) => {
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
};
setLocal((prev) => ({...prev, 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 +94,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} /> :
( (

View File

@@ -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,
}; };
}; };

View File

@@ -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}
}); });
} }
}; };

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { 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}
/> />
); );
}; };

View File

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

View File

@@ -0,0 +1,121 @@
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";
import Speaking from "../../Exercises/Speaking";
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" />
};
case "speaking":
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`Speaking Section 2`}
firstId={exercise.sectionId!.toString()}
lastId={exercise.sectionId!.toString()}
prompt={exercise.prompts[2]}
/>
),
content: <Speaking key={exercise.id} exercise={exercise} sectionId={sectionId} qId={index} module="level" />
};
case "interactiveSpeaking":
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`Speaking Section 2`}
firstId={exercise.sectionId!.toString()}
lastId={exercise.sectionId!.toString()}
prompt={exercise.prompts[2].text}
/>
),
content: <Speaking key={exercise.id} exercise={exercise} sectionId={sectionId} qId={index} module="level" />
};
default:
return {} as unknown as ExerciseItem;
}
}).filter(isExerciseItem);
/*return mappedItems.filter((item): item is ExerciseItem =>
item !== null && isExerciseItem(item)
);*/
return items;
};
export default getExerciseItems;

View File

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

View File

@@ -1,8 +1,7 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { 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,11 +180,13 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
); );
} }
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} />); if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} module="writing" />);
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} />); if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} module="speaking" />);
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 {
@@ -122,19 +196,17 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
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 >
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import { FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
export default interface ExerciseItem { 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 &&

View File

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

View File

@@ -7,6 +7,8 @@ import useExamEditorStore from '@/stores/examEditor';
import { ModuleState } from '@/stores/examEditor/types'; import { 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>
)} )}

View File

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

View File

@@ -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);
});
} }

View File

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

View File

@@ -0,0 +1,102 @@
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 | undefined>(undefined);
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) => {
const newValue = currentValue === value ? undefined : value;
setSelectedValue(newValue);
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: "level",
field: module === "reading" ? "readingSection" : "listeningSection",
value: newValue
}
});
};
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`}
`}
onClick={(e) => {
e.preventDefault();
handleSectionChange(num);
}}
>
<input
type="checkbox"
checked={currentValue === num}
onChange={() => {}}
className={`
h-5 w-5 cursor-pointer
accent-ielts-${module}
`}
/>
<div className="flex items-center space-x-2">
<span>
{module === "reading" ? `Passage ${num}` : `Section ${num}`}
</span>
</div>
</label>
))}
</div>
</Dropdown>
);
};
export default SectionPicker;

View File

@@ -10,9 +10,10 @@ interface Props {
setIsOpen: (isOpen: boolean) => void; 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}

View File

@@ -1,18 +1,24 @@
import { Exercise, LevelExam, LevelPart } from "@/interfaces/exam"; import { Exercise, InteractiveSpeakingExercise, LevelExam, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import SettingsEditor from "."; import 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 = () => {
@@ -27,7 +33,7 @@ const LevelSettings: React.FC = () => {
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.find((ex) => ex.id === focusedExercise.id) 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 ${focusedExercise?.questionId}`} className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
>
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise }}
level
/>
</Dropdown>
}
</Dropdown>
</div>
*/}
</SettingsEditor >
); );
}; };

View File

@@ -0,0 +1,206 @@
import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker";
import GenerateBtn from "../Shared/GenerateBtn";
import { useCallback } from "react";
import { generate } from "../Shared/Generate";
import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { LevelPart, ListeningPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import axios from "axios";
import { toast } from "react-toastify";
import { playSound } from "@/utils/sound";
interface Props {
localSettings: ListeningSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ListeningSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ListeningPart | LevelPart;
audioContextDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, updateLocalAndScheduleGlobal, levelId, level = false, audioContextDisabled = false }) => {
const { currentModule, dispatch, modules } = useExamEditorStore();
const {
focusedSection,
difficulty,
} = useExamEditorStore(state => state.modules[currentModule]);
const generateScript = useCallback(() => {
generate(
levelId ? levelId : focusedSection,
"listening",
"listeningScript",
{
method: 'GET',
queryParams: {
difficulty,
...(localSettings.listeningTopic && { topic: localSettings.listeningTopic })
}
},
(data: any) => [{
script: data.dialog
}],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.listeningTopic, difficulty, focusedSection, levelId, level]);
const onTopicChange = useCallback((listeningTopic: string) => {
updateLocalAndScheduleGlobal({ listeningTopic });
}, [updateLocalAndScheduleGlobal]);
const generateAudio = useCallback(async (sectionId: number) => {
let body: any;
if ([1, 3].includes(levelId ? levelId : focusedSection)) {
body = { conversation: currentSection.script }
} else {
body = { monologue: currentSection.script }
}
try {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: "level", field: "levelGenerating", value:
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating, "audio"]
}
});
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "audio" } });
}
const response = await axios.post(
'/api/exam/media/listening',
body,
{
responseType: 'arraybuffer',
headers: {
'Accept': 'audio/mpeg'
},
}
);
const blob = new Blob([response.data], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
if (currentSection.audio?.source) {
URL.revokeObjectURL(currentSection.audio?.source)
}
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: level ? "level" : "listening",
update: {
audio: {
source: url,
repeatableTimes: 3
}
}
}
});
playSound("check");
toast.success('Audio generated successfully!');
} catch (error: any) {
toast.error('Failed to generate audio');
} finally {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: "level", field: "levelGenerating", value:
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating.filter(g => g !== "audio")]
}
});
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSection?.script, dispatch, level, levelId]);
return (
<>
<Dropdown
title="Audio Context"
module="listening"
open={localSettings.isAudioContextOpen}
disabled={audioContextDisabled}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.listeningTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="listening"
genType="listeningScript"
sectionId={focusedSection}
generateFnc={generateScript}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="listening"
open={localSettings.isListeningTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningTopicOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<ExercisePicker
module="listening"
sectionId={levelId !== undefined ? levelId : focusedSection}
difficulty={difficulty}
extraArgs={{ script: currentSection === undefined || currentSection.audio === undefined ? "" : currentSection.script }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
<Dropdown
title="Generate Audio"
module="listening"
open={localSettings.isAudioGenerationOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4">
<span className="bg-gray-100 px-3.5 py-2.5 rounded-l-lg border border-r-0 border-gray-300">
Generate audio recording for this section
</span>
<div className="-ml-2.5">
<GenerateBtn
module="listening"
genType="audio"
sectionId={levelId ? levelId : focusedSection}
generateFnc={generateAudio}
levelId={focusedSection}
/>
</div>
</div>
</Dropdown>
</>
);
};
export default ListeningComponents;

View File

@@ -1,13 +1,13 @@
import Dropdown from "./Shared/SettingsDropdown"; import 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>
); );
}; };

View File

@@ -0,0 +1,108 @@
import React, { useCallback } from "react";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import ExercisePicker from "../../ExercisePicker";
import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn";
import { LevelPart, ReadingPart } from "@/interfaces/exam";
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
interface Props {
localSettings: ReadingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => {
const { currentModule } = useExamEditorStore();
const {
focusedSection,
difficulty,
} = useExamEditorStore(state => state.modules[currentModule]);
const generatePassage = useCallback(() => {
generate(
levelId ? levelId : focusedSection,
"reading",
"passage",
{
method: 'GET',
queryParams: {
difficulty,
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
}
},
(data: any) => [{
title: data.title,
text: data.text
}],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
const onTopicChange = useCallback((readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
}, [updateLocalAndScheduleGlobal]);
return (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={generatePassageDisabled}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
difficulty={difficulty}
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
);
};
export default ReadingComponents;

View File

@@ -1,12 +1,7 @@
import React, { useCallback, useState } from "react"; import 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>
); );
}; };

View File

@@ -1,481 +0,0 @@
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState";
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option";
import { useCallback, useState } from "react";
import { generate } from "./Shared/Generate";
import SettingsEditor from ".";
import Dropdown from "./Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import GenerateBtn from "./Shared/GenerateBtn";
import clsx from "clsx";
import { FaFemale, FaMale, FaChevronDown } from "react-icons/fa";
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { generateVideos } from "./Shared/generateVideos";
import { usePersistentExamStore } from "@/stores/examStore";
import { useRouter } from "next/router";
import openDetachedTab from "@/utils/popout";
import axios from "axios";
import { playSound } from "@/utils/sound";
export interface Avatar {
name: string;
gender: string;
}
const SpeakingSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { title, currentModule, speakingAvatars, dispatch } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
currentModule,
focusedSection,
);
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
const defaultPresets: Option[] = [
{
label: "Preset: Speaking Part 1",
value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
},
{
label: "Preset: Speaking Part 2",
value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
},
{
label: "Preset: Speaking Part 3",
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
}
];
const generateScript = useCallback((sectionId: number) => {
const queryParams: {
difficulty: string;
first_topic?: string;
second_topic?: string;
topic?: string;
} = { difficulty };
if (sectionId === 1) {
if (localSettings.topic) {
queryParams['first_topic'] = localSettings.topic;
}
if (localSettings.secondTopic) {
queryParams['second_topic'] = localSettings.secondTopic;
}
} else {
if (localSettings.topic) {
queryParams['topic'] = localSettings.topic;
}
}
generate(
sectionId,
currentModule,
"context", // <- not really context but exercises is reserved for reading, listening and level
{
method: 'GET',
queryParams
},
(data: any) => {
switch (sectionId) {
case 1:
return [{
prompts: data.questions,
first_topic: data.first_topic,
second_topic: data.second_topic
}];
case 2:
return [{
topic: data.topic,
question: data.question,
prompts: data.prompts,
suffix: data.suffix
}];
case 3:
return [{
title: data.topic,
prompts: data.questions
}];
default:
return [data];
}
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings, difficulty]);
const onTopicChange = useCallback((topic: string) => {
updateLocalAndScheduleGlobal({ topic });
}, [updateLocalAndScheduleGlobal]);
const onSecondTopicChange = useCallback((topic: string) => {
updateLocalAndScheduleGlobal({ secondTopic: topic });
}, [updateLocalAndScheduleGlobal]);
const canPreviewOrSubmit = (() => {
return sections.every((s) => {
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
switch (section.type) {
case 'speaking':
return section.title !== '' &&
section.text !== '' &&
section.video_url !== '' &&
section.prompts.every(prompt => prompt !== '');
case 'interactiveSpeaking':
if ('first_title' in section && 'second_title' in section) {
return section.first_title !== '' &&
section.second_title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '') &&
section.prompts.length > 2;
}
return section.title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '');
default:
return false;
}
});
})();
const canGenerate = section && (() => {
switch (focusedSection) {
case 1: {
const currentSection = section as InteractiveSpeakingExercise;
return currentSection.first_title !== "" &&
currentSection.second_title !== "" &&
currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2;
}
case 2: {
const currentSection = section as SpeakingExercise;
return currentSection.title !== "" &&
currentSection.text !== "" &&
currentSection.prompts.every(prompt => prompt !== "");
}
case 3: {
const currentSection = section as InteractiveSpeakingExercise;
return currentSection.title !== "" &&
currentSection.prompts.every(prompt => prompt.text !== "");
}
default:
return false;
}
})();
const generateVideoCallback = useCallback((sectionId: number) => {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "media" } })
generateVideos(
section as InteractiveSpeakingExercise | SpeakingExercise,
sectionId,
selectedAvatar,
speakingAvatars
).then((results) => {
switch (sectionId) {
case 1:
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
...prompt,
video_url: results[index].url || ''
}));
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ prompts: updatedPrompts }] } })
break;
}
case 2: {
if (results[0]?.url) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ video_url: results[0].url }] } })
}
break;
}
}
}).catch((error) => {
toast.error("Failed to generate the video, try again later!")
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAvatar, section]);
const submitSpeaking = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
const formData = new FormData();
const urlMap = new Map<string, string>();
const sectionsWithVideos = sections.filter(s => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.some(prompt => prompt.video_url !== "");
}
return false;
});
if (sectionsWithVideos.length === 0) {
toast.error('No video sections found in the exam! Please record or import videos.');
return;
}
await Promise.all(
sectionsWithVideos.map(async (section) => {
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}`, exercise.video_url);
} else {
await Promise.all(
exercise.prompts.map(async (prompt, promptIndex) => {
if (prompt.video_url) {
const response = await fetch(prompt.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'speaking_videos'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { urls } = response.data;
const exam: SpeakingExam = {
exercises: sections.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}`);
return {
...exercise,
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
intro: s.settings.currentIntro,
category: s.settings.category
};
} else {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts,
intro: s.settings.currentIntro,
category: s.settings.category
};
}
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
instructorGender: "varied",
private: isPrivate,
};
const result = await axios.post('/api/exam/speaking', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
Array.from(urlMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
exercises: sections
.filter((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.every(prompt => prompt.video_url !== "");
}
return false;
})
.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
} as SpeakingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=speaking", router)
}
return (
<SettingsEditor
sectionLabel={`Speaking ${focusedSection}`}
sectionId={focusedSection}
module="speaking"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitSpeaking}
>
<Dropdown
title="Generate Script"
module={currentModule}
open={localSettings.isExerciseDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
>
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center")}>
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.topic}
/>
</div>
{focusedSection === 1 &&
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onSecondTopicChange}
roundness="full"
value={localSettings.secondTopic}
/>
</div>
}
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
<GenerateBtn
module={currentModule}
genType="context"
sectionId={focusedSection}
generateFnc={generateScript}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Generate Video"
module={currentModule}
open={localSettings.isGenerateAudioOpen}
disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)}
>
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs">
<select
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
onChange={(e) => {
if (e.target.value === "") {
setSelectedAvatar(null);
} else {
const [name, gender] = e.target.value.split("-");
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
if (avatar) setSelectedAvatar(avatar);
}
}}
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">Select an avatar</option>
{speakingAvatars.map((avatar) => (
<option
key={`${avatar.name}-${avatar.gender}`}
value={`${avatar.name}-${avatar.gender}`}
>
{avatar.name}
</option>
))}
</select>
<div className="absolute right-2.5 top-2.5 pointer-events-none">
{selectedAvatar && (
selectedAvatar.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)
)}
</div>
</div>
<GenerateBtn
module={currentModule}
genType="media"
sectionId={focusedSection}
generateFnc={generateVideoCallback}
/>
</div>
</Dropdown>
</SettingsEditor>
);
};
export default SpeakingSettings;

View File

@@ -0,0 +1,268 @@
import useExamEditorStore from "@/stores/examEditor";
import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types";
import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import GenerateBtn from "../Shared/GenerateBtn";
import clsx from "clsx";
import { FaFemale, FaMale } from "react-icons/fa";
import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { generateVideos } from "../Shared/generateVideos";
import { Module } from "@/interfaces";
export interface Avatar {
name: string;
gender: string;
}
interface Props {
localSettings: SpeakingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<SpeakingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart;
level?: boolean;
module?: Module;
}
const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, section, level, module = "speaking" }) => {
const { currentModule, speakingAvatars, dispatch } = useExamEditorStore();
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
const generateScript = useCallback((sectionId: number) => {
const queryParams: {
difficulty: string;
first_topic?: string;
second_topic?: string;
topic?: string;
} = { difficulty };
if (sectionId === 1) {
if (localSettings.speakingTopic) {
queryParams['first_topic'] = localSettings.speakingTopic;
}
if (localSettings.speakingSecondTopic) {
queryParams['second_topic'] = localSettings.speakingSecondTopic;
}
} else {
if (localSettings.speakingTopic) {
queryParams['topic'] = localSettings.speakingTopic;
}
}
generate(
sectionId,
currentModule,
"speakingScript",
{
method: 'GET',
queryParams
},
(data: any) => {
switch (sectionId) {
case 1:
return [{
prompts: data.questions,
first_topic: data.first_topic,
second_topic: data.second_topic
}];
case 2:
return [{
topic: data.topic,
question: data.question,
prompts: data.prompts,
suffix: data.suffix
}];
case 3:
return [{
title: data.topic,
prompts: data.questions
}];
default:
return [data];
}
}
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings, difficulty]);
const onTopicChange = useCallback((speakingTopic: string) => {
updateLocalAndScheduleGlobal({ speakingTopic });
}, [updateLocalAndScheduleGlobal]);
const onSecondTopicChange = useCallback((speakingSecondTopic: string) => {
updateLocalAndScheduleGlobal({ speakingSecondTopic });
}, [updateLocalAndScheduleGlobal]);
const canGenerate = section && (() => {
switch (focusedSection) {
case 1: {
const currentSection = section as InteractiveSpeakingExercise;
return currentSection.first_title !== "" &&
currentSection.second_title !== "" &&
currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2;
}
case 2: {
const currentSection = section as SpeakingExercise;
return currentSection.title !== "" &&
currentSection.text !== "" &&
currentSection.prompts.every(prompt => prompt !== "");
}
case 3: {
const currentSection = section as InteractiveSpeakingExercise;
return currentSection.title !== "" &&
currentSection.prompts.every(prompt => prompt.text !== "");
}
default:
return false;
}
})();
const generateVideoCallback = useCallback((sectionId: number) => {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "video" } })
generateVideos(
section as InteractiveSpeakingExercise | SpeakingExercise,
sectionId,
selectedAvatar,
speakingAvatars
).then((results) => {
switch (sectionId) {
case 1:
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
...prompt,
video_url: results[index].url || ''
}));
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: currentModule, field: "genResult", value:
{ generating: "video", result: [{ prompts: updatedPrompts }], module: module }
}
})
break;
}
case 2: {
if (results[0]?.url) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: currentModule, field: "genResult", value:
{ generating: "video", result: [{ video_url: results[0].url }], module: module }
}
})
}
break;
}
}
}).catch((error) => {
toast.error("Failed to generate the video, try again later!")
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAvatar, section]);
return (
<>
<Dropdown
title="Generate Script"
module="speaking"
open={localSettings.isSpeakingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
>
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center")}>
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.speakingTopic}
/>
</div>
{focusedSection === 1 &&
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onSecondTopicChange}
roundness="full"
value={localSettings.speakingSecondTopic}
/>
</div>
}
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
<GenerateBtn
module="speaking"
genType="speakingScript"
sectionId={focusedSection}
generateFnc={generateScript}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Generate Video"
module="speaking"
open={localSettings.isGenerateVideoOpen}
disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)}
>
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs">
<select
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
onChange={(e) => {
if (e.target.value === "") {
setSelectedAvatar(null);
} else {
const [name, gender] = e.target.value.split("-");
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
if (avatar) setSelectedAvatar(avatar);
}
}}
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">Select an avatar (Optional)</option>
{speakingAvatars.map((avatar) => (
<option
key={`${avatar.name}-${avatar.gender}`}
value={`${avatar.name}-${avatar.gender}`}
>
{avatar.name}
</option>
))}
</select>
<div className="absolute right-2.5 top-2.5 pointer-events-none">
{selectedAvatar && (
selectedAvatar.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)
)}
</div>
</div>
<GenerateBtn
module="speaking"
genType="video"
sectionId={focusedSection}
generateFnc={generateVideoCallback}
/>
</div>
</Dropdown>
</>
);
};
export default SpeakingComponents;

View File

@@ -0,0 +1,261 @@
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../../Hooks/useSettingsState";
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option";
import SettingsEditor from "..";
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { usePersistentExamStore } from "@/stores/examStore";
import { useRouter } from "next/router";
import openDetachedTab from "@/utils/popout";
import axios from "axios";
import { playSound } from "@/utils/sound";
import SpeakingComponents from "./components";
export interface Avatar {
name: string;
gender: string;
}
const SpeakingSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { title, currentModule } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
currentModule,
focusedSection,
);
if (section === undefined) return <></>;
const currentSection = section as SpeakingExercise | InteractiveSpeakingExercise;
const defaultPresets: Option[] = [
{
label: "Preset: Speaking Part 1",
value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
},
{
label: "Preset: Speaking Part 2",
value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
},
{
label: "Preset: Speaking Part 3",
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
}
];
const canPreviewOrSubmit = (() => {
return sections.every((s) => {
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
switch (section.type) {
case 'speaking':
return section.title !== '' &&
section.text !== '' &&
section.video_url !== '' &&
section.prompts.every(prompt => prompt !== '');
case 'interactiveSpeaking':
if ('first_title' in section && 'second_title' in section) {
return section.first_title !== '' &&
section.second_title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '') &&
section.prompts.length > 2;
}
return section.title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '');
default:
return false;
}
});
})();
const submitSpeaking = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
const formData = new FormData();
const urlMap = new Map<string, string>();
const sectionsWithVideos = sections.filter(s => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.some(prompt => prompt.video_url !== "");
}
return false;
});
if (sectionsWithVideos.length === 0) {
toast.error('No video sections found in the exam! Please record or import videos.');
return;
}
await Promise.all(
sectionsWithVideos.map(async (section) => {
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}`, exercise.video_url);
} else {
await Promise.all(
exercise.prompts.map(async (prompt, promptIndex) => {
if (prompt.video_url) {
const response = await fetch(prompt.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'speaking_videos'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { urls } = response.data;
const exam: SpeakingExam = {
exercises: sections.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}`);
return {
...exercise,
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
intro: s.settings.currentIntro,
category: s.settings.category
};
} else {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts,
intro: s.settings.currentIntro,
category: s.settings.category
};
}
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
instructorGender: "varied",
private: isPrivate,
};
const result = await axios.post('/api/exam/speaking', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
Array.from(urlMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
exercises: sections
.filter((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.every(prompt => prompt.video_url !== "");
}
return false;
})
.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
} as SpeakingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=speaking", router)
}
return (
<SettingsEditor
sectionLabel={`Speaking ${focusedSection}`}
sectionId={focusedSection}
module="speaking"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitSpeaking}
>
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: currentSection }}
/>
</SettingsEditor>
);
};
export default SpeakingSettings;

View File

@@ -0,0 +1,85 @@
import React, { useCallback, useState } from "react";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn";
import { LevelSectionSettings, WritingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { WritingExercise } from "@/interfaces/exam";
interface Props {
localSettings: WritingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<WritingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection?: WritingExercise;
level?: boolean;
}
const WritingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, level}) => {
const { currentModule } = useExamEditorStore();
const {
difficulty,
focusedSection,
} = useExamEditorStore((store) => store.modules["writing"]);
const generatePassage = useCallback((sectionId: number) => {
generate(
sectionId,
currentModule,
"writing",
{
method: 'GET',
queryParams: {
difficulty,
...(localSettings.writingTopic && { topic: localSettings.writingTopic })
}
},
(data: any) => [{
prompt: data.question
}]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.writingTopic, difficulty]);
const onTopicChange = useCallback((writingTopic: string) => {
updateLocalAndScheduleGlobal({ writingTopic });
}, [updateLocalAndScheduleGlobal]);
return (
<>
<Dropdown
title="Generate Instructions"
module={"writing"}
open={localSettings.isWritingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-writing`: ''}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.writingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
genType="writing"
module={"writing"}
sectionId={focusedSection}
generateFnc={generatePassage}
/>
</div>
</div>
</Dropdown>
</>
);
};
export default WritingComponents;

View File

@@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useState } from "react"; import 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>
); );
}; };

View File

@@ -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 <>
&quot;{text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""}...&quot;
</>
}
const label = (type: string, firstId: string, lastId: string) => {
return `${type} #${firstId} ${firstId === lastId ? '' : `- #${lastId}`}`;
}
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
return ( 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>
); );
} }

View File

@@ -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>
); );

View File

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

View File

@@ -305,7 +305,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"
/> />

View File

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

View File

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

View File

@@ -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}`,

View File

@@ -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" }];
} }
} }
@@ -98,14 +136,15 @@ export const defaultSectionSettings = (module: Module, sectionId: number, part?:
state: part !== undefined ? part : defaultSection(module, sectionId), state: part !== undefined ? part : defaultSection(module, sectionId),
generating: undefined, generating: undefined,
genResult: undefined, genResult: undefined,
focusedExercise: undefined,
expandedSubSections: [], expandedSubSections: [],
exercisePickerState: [], levelGenerating: [],
selectedExercises: [], levelGenResults: []
} }
} }
const defaultModuleSettings = (module: Module, minTimer: number, states?: SectionState[]): ModuleState => { const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => {
const state: ModuleState = { const state: ModuleState = {
examLabel: defaultExamLabel(module), examLabel: defaultExamLabel(module),
minTimer, minTimer,

View File

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

View File

@@ -1,4 +1,4 @@
import { Exercise, FillBlanksExercise, LevelPart, ListeningPart, MatchSentencesExercise, MultipleChoiceExercise, ReadingPart, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam"; import { Exercise, FillBlanksExercise, LevelPart, ListeningPart, MatchSentencesExercise, MultipleChoiceExercise, ReadingPart, TrueFalseExercise, WriteBlanksExercise, WritingExercise } from "@/interfaces/exam";
import { ModuleState } from "../types"; import { 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 {

View File

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

View File

@@ -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?: {questionId: number; id: string} | undefined;
writingSection?: number;
speakingSection?: number;
readingSection?: number;
listeningSection?: number;
} }
export interface ModuleState { export interface ModuleState {