ENCOA-228 Now when user navigates between modules the generation items persist. Reading, listening and writing added to level module
This commit is contained in:
@@ -14,27 +14,51 @@ interface GeneratorConfig {
|
||||
export function generate(
|
||||
sectionId: number,
|
||||
module: Module,
|
||||
type: "context" | "exercises",
|
||||
type: Generating,
|
||||
config: GeneratorConfig,
|
||||
mapData: (data: any) => Record<string, any>[]
|
||||
mapData: (data: any) => Record<string, any>[],
|
||||
levelSectionId?: number,
|
||||
level: boolean = false
|
||||
) {
|
||||
const dispatch = useExamEditorStore.getState().dispatch;
|
||||
const setGenerating = (sectionId: number, generating: Generating, level: boolean, remove?: boolean) => {
|
||||
const state = useExamEditorStore.getState();
|
||||
const dispatch = state.dispatch;
|
||||
let generatingUpdate;
|
||||
if (level) {
|
||||
if (remove) {
|
||||
generatingUpdate = state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating.filter(g => g === generating)
|
||||
}
|
||||
else {
|
||||
generatingUpdate = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating, generating];
|
||||
}
|
||||
} else {
|
||||
generatingUpdate = generating;
|
||||
}
|
||||
|
||||
const setGenerating = (sectionId: number, generating: Generating) => {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: { sectionId, module, field: "generating", value: generating }
|
||||
payload: { sectionId : sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate }
|
||||
});
|
||||
};
|
||||
|
||||
const setGeneratedExercises = (sectionId: number, exercises: Record<string, any>[] | undefined) => {
|
||||
const setGeneratedResult = (sectionId: number, generating: Generating, result: Record<string, any>[] | undefined, level: boolean) => {
|
||||
const state = useExamEditorStore.getState();
|
||||
const dispatch = state.dispatch;
|
||||
|
||||
let genResults;
|
||||
if (level) {
|
||||
genResults = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenResults, { generating, result, module }];
|
||||
} else {
|
||||
genResults = { generating, result, module };
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: { sectionId, module, field: "genResult", value: exercises }
|
||||
payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults }
|
||||
});
|
||||
};
|
||||
|
||||
setGenerating(sectionId, type);
|
||||
setGenerating(level ? levelSectionId! : sectionId, type, level);
|
||||
|
||||
const queryString = config.queryParams
|
||||
? new URLSearchParams(config.queryParams).toString()
|
||||
@@ -49,13 +73,11 @@ export function generate(
|
||||
request
|
||||
.then((result) => {
|
||||
playSound("check");
|
||||
setGeneratedExercises(sectionId, mapData(result.data));
|
||||
setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level);
|
||||
})
|
||||
.catch((error) => {
|
||||
setGenerating(sectionId, undefined, level, true);
|
||||
playSound("error");
|
||||
toast.error("Something went wrong! Try to generate again.");
|
||||
})
|
||||
.finally(() => {
|
||||
setGenerating(sectionId, undefined);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { Generating } from "@/stores/examEditor/types";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
import { GiBrain } from "react-icons/gi";
|
||||
|
||||
@@ -11,25 +12,37 @@ interface Props {
|
||||
genType: Generating;
|
||||
generateFnc: (sectionId: number) => void
|
||||
className?: string;
|
||||
levelId?: number;
|
||||
level?: boolean;
|
||||
}
|
||||
|
||||
const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc, className}) => {
|
||||
const section = useExamEditorStore((store) => store.modules[module].sections.find((s)=> s.sectionId == sectionId));
|
||||
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, levelId }) => {
|
||||
const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == levelId ? levelId : sectionId));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const generating = section?.generating;
|
||||
const levelGenerating = section?.levelGenerating;
|
||||
|
||||
useEffect(()=> {
|
||||
const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType);
|
||||
if (loading !== gen) {
|
||||
setLoading(gen);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [generating, levelGenerating])
|
||||
|
||||
if (section === undefined) return <></>;
|
||||
|
||||
const {generating} = section;
|
||||
|
||||
|
||||
const loading = generating && generating === genType;
|
||||
return (
|
||||
<button
|
||||
key={`section-${sectionId}`}
|
||||
className={clsx(
|
||||
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module}`,
|
||||
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`,
|
||||
className
|
||||
)}
|
||||
onClick={loading ? () => { } : () => generateFnc(sectionId)}
|
||||
disabled={loading}
|
||||
onClick={loading ? () => { } : () => generateFnc(levelId ? levelId : sectionId)}
|
||||
>
|
||||
{loading ? (
|
||||
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
||||
@@ -43,6 +56,6 @@ const GenerateBtn: React.FC<Props> = ({module, sectionId, genType, generateFnc,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default GenerateBtn;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import Dropdown from "./SettingsDropdown";
|
||||
import { LevelSectionSettings } from "@/stores/examEditor/types";
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
sectionId: number;
|
||||
localSettings: LevelSectionSettings;
|
||||
updateLocalAndScheduleGlobal: (updates: Partial<LevelSectionSettings>, schedule?: boolean) => void;
|
||||
}
|
||||
|
||||
const SectionPicker: React.FC<Props> = ({
|
||||
module,
|
||||
sectionId,
|
||||
localSettings,
|
||||
updateLocalAndScheduleGlobal
|
||||
}) => {
|
||||
const { dispatch } = useExamEditorStore();
|
||||
const [selectedValue, setSelectedValue] = React.useState<number | null>(null);
|
||||
|
||||
const sectionState = useExamEditorStore(state =>
|
||||
state.modules["level"].sections.find((s) => s.sectionId === sectionId)
|
||||
);
|
||||
|
||||
if (sectionState === undefined) return null;
|
||||
|
||||
const { readingSection, listeningSection } = sectionState;
|
||||
const currentValue = selectedValue ?? (module === "reading" ? readingSection : listeningSection);
|
||||
const options = module === "reading" ? [1, 2, 3] : [1, 2, 3, 4];
|
||||
const openPicker = module === "reading" ? "isReadingPickerOpen" : "isListeningPickerOpen";
|
||||
|
||||
const handleSectionChange = (value: number) => {
|
||||
setSelectedValue(value);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: "level",
|
||||
field: module === "reading" ? "readingSection" : "listeningSection",
|
||||
value: value
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
const section = module === "reading" ? "Passage" : "Section";
|
||||
if (!currentValue) return `Choose a ${section}`;
|
||||
return `${section} ${currentValue}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
title={getTitle()}
|
||||
module={module}
|
||||
open={localSettings[openPicker]}
|
||||
setIsOpen={(isOpen: boolean) =>
|
||||
updateLocalAndScheduleGlobal({ [openPicker]: isOpen }, false)
|
||||
}
|
||||
contentWrapperClassName={`pt-6 px-4 bg-gray-200 rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-${module}`}
|
||||
>
|
||||
<div className="space-y-2 pt-3 pb-3 px-2 border border-gray-200 rounded-lg shadow-inner">
|
||||
{options.map((num) => (
|
||||
<label
|
||||
key={num}
|
||||
className={`
|
||||
flex items-center space-x-3 font-semibold cursor-pointer p-2 rounded
|
||||
transition-colors duration-200
|
||||
${currentValue === num
|
||||
? `bg-ielts-${module}/90 text-white`
|
||||
: `hover:bg-ielts-${module}/70 text-gray-700`}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${module === "reading" ? 'passage' : 'section'}-${sectionId}`}
|
||||
value={num}
|
||||
checked={currentValue === num}
|
||||
onChange={() => handleSectionChange(num)}
|
||||
className={`
|
||||
h-5 w-5 cursor-pointer
|
||||
accent-ielts-${module}
|
||||
`}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>
|
||||
{module === "reading" ? `Passage ${num}` : `Section ${num}`}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionPicker;
|
||||
@@ -10,9 +10,10 @@ interface Props {
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
children: ReactNode;
|
||||
center?: boolean;
|
||||
contentWrapperClassName?: string;
|
||||
}
|
||||
|
||||
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, disabled = false, center = false}) => {
|
||||
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, contentWrapperClassName = '', disabled = false, center = false}) => {
|
||||
return (
|
||||
<Dropdown
|
||||
title={title}
|
||||
@@ -21,7 +22,7 @@ const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, chi
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
|
||||
open ? "rounded-t-lg" : "rounded-lg"
|
||||
)}
|
||||
contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${center ? "flex justify-center" : ""}`}
|
||||
contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${center ? "flex justify-center" : ""} ${contentWrapperClassName}`}
|
||||
open={open}
|
||||
setIsOpen={setIsOpen}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Exercise, LevelExam, LevelPart } from "@/interfaces/exam";
|
||||
import { Exercise, InteractiveSpeakingExercise, LevelExam, LevelPart, SpeakingExercise } from "@/interfaces/exam";
|
||||
import SettingsEditor from ".";
|
||||
import Option from "@/interfaces/option";
|
||||
import Dropdown from "@/components/Dropdown";
|
||||
import clsx from "clsx";
|
||||
import ExercisePicker from "../Shared/ExercisePicker";
|
||||
import ExercisePicker from "../ExercisePicker";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import { SectionSettings } from "@/stores/examEditor/types";
|
||||
import { LevelSectionSettings, SectionSettings } from "@/stores/examEditor/types";
|
||||
import { toast } from "react-toastify";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import ListeningComponents from "./listening/components";
|
||||
import ReadingComponents from "./reading/components";
|
||||
import WritingComponents from "./writing/components";
|
||||
import SpeakingComponents from "./speaking/components";
|
||||
import SectionPicker from "./Shared/SectionPicker";
|
||||
import SettingsDropdown from "./Shared/SettingsDropdown";
|
||||
|
||||
|
||||
const LevelSettings: React.FC = () => {
|
||||
@@ -26,8 +32,8 @@ const LevelSettings: React.FC = () => {
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const {currentModule, title } = useExamEditorStore();
|
||||
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
@@ -36,15 +42,18 @@ const LevelSettings: React.FC = () => {
|
||||
isPrivate,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection
|
||||
);
|
||||
|
||||
const section = sections.find((section) => section.sectionId == focusedSection);
|
||||
const focusedExercise = section?.focusedExercise;
|
||||
if (section === undefined) return <></>;
|
||||
|
||||
const currentSection = section.state as LevelPart;
|
||||
const readingSection = section.readingSection;
|
||||
const listeningSection = section.listeningSection;
|
||||
|
||||
const canPreview = currentSection.exercises.length > 0;
|
||||
|
||||
@@ -105,6 +114,8 @@ const LevelSettings: React.FC = () => {
|
||||
openDetachedTab("popout?type=Exam&module=level", router)
|
||||
}
|
||||
|
||||
const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises[focusedExercise] as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Part ${focusedSection}`}
|
||||
@@ -117,26 +128,147 @@ const LevelSettings: React.FC = () => {
|
||||
submitModule={submitLevel}
|
||||
>
|
||||
<div>
|
||||
<Dropdown title="Add Exercises" className={
|
||||
<Dropdown title="Add Level Exercises" className={
|
||||
clsx(
|
||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||
"bg-ielts-level/70 border-ielts-level hover:bg-ielts-level",
|
||||
"text-white shadow-md transition-all duration-300",
|
||||
localSettings.isExerciseDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
localSettings.isLevelDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
)
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
open={localSettings.isLevelDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isLevelDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="level"
|
||||
sectionId={focusedSection}
|
||||
difficulty={difficulty}
|
||||
/>
|
||||
module="level"
|
||||
sectionId={focusedSection}
|
||||
difficulty={difficulty}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</SettingsEditor>
|
||||
<div>
|
||||
<Dropdown title="Add Reading Exercises" className={
|
||||
clsx(
|
||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||
"bg-ielts-reading/70 border-ielts-reading hover:bg-ielts-reading",
|
||||
"text-white shadow-md transition-all duration-300",
|
||||
localSettings.isReadingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
)
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
|
||||
open={localSettings.isReadingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="space-y-2 px-2 pb-2">
|
||||
<SectionPicker {...{ module: "reading", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, generatePassageDisabled: readingSection === undefined, levelId: readingSection, level: true }}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown title="Add Listening Exercises" className={
|
||||
clsx(
|
||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||
"bg-ielts-listening/70 border-ielts-listening hover:bg-ielts-listening",
|
||||
"text-white shadow-md transition-all duration-300",
|
||||
localSettings.isListeningDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
)
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
|
||||
open={localSettings.isListeningDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="space-y-2 px-2 pb-2">
|
||||
<SectionPicker {...{ module: "listening", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
|
||||
<ListeningComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, audioContextDisabled: listeningSection === undefined, levelId: listeningSection, level: true }}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown title="Add Writing Exercises" className={
|
||||
clsx(
|
||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||
"bg-ielts-writing/70 border-ielts-writing hover:bg-ielts-writing",
|
||||
"text-white shadow-md transition-all duration-300",
|
||||
localSettings.isWritingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
)
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
|
||||
open={localSettings.isWritingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="writing"
|
||||
sectionId={focusedSection}
|
||||
difficulty={difficulty}
|
||||
levelSectionId={focusedSection}
|
||||
level
|
||||
/>
|
||||
</Dropdown>
|
||||
</div >
|
||||
<div>
|
||||
<Dropdown title="Add Speaking Exercises" className={
|
||||
clsx(
|
||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
|
||||
"text-white shadow-md transition-all duration-300",
|
||||
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
)
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
|
||||
open={localSettings.isSpeakingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<Dropdown title="Exercises" className={
|
||||
clsx(
|
||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
|
||||
"text-white shadow-md transition-all duration-300",
|
||||
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
)
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
|
||||
open={localSettings.isSpeakingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="space-y-2 px-2 pb-2">
|
||||
<ExercisePicker
|
||||
module="speaking"
|
||||
sectionId={focusedSection}
|
||||
difficulty={difficulty}
|
||||
levelSectionId={focusedSection}
|
||||
level
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{speakingExercise !== undefined &&
|
||||
<Dropdown title="Configure Speaking Exercise" className={
|
||||
clsx(
|
||||
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
|
||||
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
|
||||
"text-white shadow-md transition-all duration-300",
|
||||
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
|
||||
)
|
||||
}
|
||||
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
|
||||
open={localSettings.isSpeakingDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
<SpeakingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise }}
|
||||
level
|
||||
/>
|
||||
</Dropdown>
|
||||
}
|
||||
</Dropdown>
|
||||
</div>
|
||||
</SettingsEditor >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import ExercisePicker from "../../ExercisePicker";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { useCallback } from "react";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { LevelPart, ListeningPart } from "@/interfaces/exam";
|
||||
import Input from "@/components/Low/Input";
|
||||
import axios from "axios";
|
||||
import { toast } from "react-toastify";
|
||||
import { playSound } from "@/utils/sound";
|
||||
|
||||
interface Props {
|
||||
localSettings: ListeningSectionSettings | LevelSectionSettings;
|
||||
updateLocalAndScheduleGlobal: (updates: Partial<ListeningSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
|
||||
currentSection: ListeningPart | LevelPart;
|
||||
audioContextDisabled?: boolean;
|
||||
levelId?: number;
|
||||
level?: boolean;
|
||||
}
|
||||
|
||||
const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, updateLocalAndScheduleGlobal, levelId, level = false, audioContextDisabled = false }) => {
|
||||
const { currentModule, dispatch, modules } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const generateScript = useCallback(() => {
|
||||
generate(
|
||||
levelId ? levelId : focusedSection,
|
||||
"listening",
|
||||
"listeningScript",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.listeningTopic && { topic: localSettings.listeningTopic })
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
script: data.dialog
|
||||
}],
|
||||
level ? focusedSection : undefined,
|
||||
level
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.listeningTopic, difficulty, focusedSection, levelId, level]);
|
||||
|
||||
const onTopicChange = useCallback((listeningTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ listeningTopic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
|
||||
const generateAudio = useCallback(async (sectionId: number) => {
|
||||
let body: any;
|
||||
if ([1, 3].includes(levelId ? levelId : focusedSection)) {
|
||||
body = { conversation: currentSection.script }
|
||||
} else {
|
||||
body = { monologue: currentSection.script }
|
||||
}
|
||||
|
||||
try {
|
||||
if (level) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, module: "level", field: "levelGenerating", value:
|
||||
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating, "audio"]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "audio" } });
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
'/api/exam/media/listening',
|
||||
body,
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'Accept': 'audio/mpeg'
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const blob = new Blob([response.data], { type: 'audio/mpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
if (currentSection.audio?.source) {
|
||||
URL.revokeObjectURL(currentSection.audio?.source)
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: level ? "level" : "listening",
|
||||
update: {
|
||||
audio: {
|
||||
source: url,
|
||||
repeatableTimes: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
playSound("check");
|
||||
toast.success('Audio generated successfully!');
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to generate audio');
|
||||
} finally {
|
||||
if (level) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, module: "level", field: "levelGenerating", value:
|
||||
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating.filter(g => g !== "audio")]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } });
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSection?.script, dispatch, level, levelId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
title="Audio Context"
|
||||
module="listening"
|
||||
open={localSettings.isAudioContextOpen}
|
||||
disabled={audioContextDisabled}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.listeningTopic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex self-end h-16 mb-1">
|
||||
<GenerateBtn
|
||||
module="listening"
|
||||
genType="listeningScript"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateScript}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
title="Add Exercises"
|
||||
module="listening"
|
||||
open={localSettings.isListeningTopicOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningTopicOpen: isOpen }, false)}
|
||||
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
|
||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="listening"
|
||||
sectionId={levelId !== undefined ? levelId : focusedSection}
|
||||
difficulty={difficulty}
|
||||
extraArgs={{ script: currentSection === undefined || currentSection.audio === undefined ? "" : currentSection.script }}
|
||||
levelSectionId={focusedSection}
|
||||
level={level}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown
|
||||
title="Generate Audio"
|
||||
module="listening"
|
||||
open={localSettings.isAudioGenerationOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
|
||||
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined || currentSection.exercises.length === 0}
|
||||
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
|
||||
>
|
||||
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4">
|
||||
<span className="bg-gray-100 px-3.5 py-2.5 rounded-l-lg border border-r-0 border-gray-300">
|
||||
Generate audio recording for this section
|
||||
</span>
|
||||
<div className="-ml-2.5">
|
||||
<GenerateBtn
|
||||
module="listening"
|
||||
genType="audio"
|
||||
sectionId={levelId ? levelId : focusedSection}
|
||||
generateFnc={generateAudio}
|
||||
levelId={focusedSection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListeningComponents;
|
||||
@@ -1,13 +1,13 @@
|
||||
import Dropdown from "./Shared/SettingsDropdown";
|
||||
import ExercisePicker from "../Shared/ExercisePicker";
|
||||
import SettingsEditor from ".";
|
||||
import GenerateBtn from "./Shared/GenerateBtn";
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import ExercisePicker from "../../ExercisePicker";
|
||||
import SettingsEditor from "..";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { useCallback, useState } from "react";
|
||||
import { generate } from "./Shared/Generate";
|
||||
import { Generating, ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
|
||||
import Option from "@/interfaces/option";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import useSettingsState from "../../Hooks/useSettingsState";
|
||||
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
|
||||
import Input from "@/components/Low/Input";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
@@ -16,10 +16,11 @@ import axios from "axios";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import ListeningComponents from "./components";
|
||||
|
||||
const ListeningSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { currentModule, title, dispatch } = useExamEditorStore();
|
||||
const { currentModule, title } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
@@ -47,45 +48,21 @@ const ListeningSettings: React.FC = () => {
|
||||
{
|
||||
label: "Preset: Listening Section 1",
|
||||
value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions."
|
||||
},
|
||||
{
|
||||
},
|
||||
{
|
||||
label: "Preset: Listening Section 2",
|
||||
value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements."
|
||||
},
|
||||
{
|
||||
},
|
||||
{
|
||||
label: "Preset: Listening Section 3",
|
||||
value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements."
|
||||
},
|
||||
{
|
||||
},
|
||||
{
|
||||
label: "Preset: Listening Section 4",
|
||||
value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject."
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const generateScript = useCallback(() => {
|
||||
generate(
|
||||
focusedSection,
|
||||
currentModule,
|
||||
"context",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.topic && { topic: localSettings.topic })
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
script: data.dialog
|
||||
}]
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.topic, difficulty, focusedSection]);
|
||||
|
||||
const onTopicChange = useCallback((topic: string) => {
|
||||
updateLocalAndScheduleGlobal({ topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const submitListening = async () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
@@ -187,56 +164,6 @@ const ListeningSettings: React.FC = () => {
|
||||
openDetachedTab("popout?type=Exam&module=listening", router)
|
||||
}
|
||||
|
||||
const generateAudio = useCallback(async (sectionId: number) => {
|
||||
let body: any;
|
||||
if ([1, 3].includes(sectionId)) {
|
||||
body = { conversation: currentSection.script }
|
||||
} else {
|
||||
body = { monologue: currentSection.script }
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: "media"}});
|
||||
const response = await axios.post(
|
||||
'/api/exam/media/listening',
|
||||
body,
|
||||
{
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'Accept': 'audio/mpeg'
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const blob = new Blob([response.data], { type: 'audio/mpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
if (currentSection.audio?.source) {
|
||||
URL.revokeObjectURL(currentSection.audio?.source)
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: {
|
||||
sectionId,
|
||||
update: {
|
||||
audio: {
|
||||
source: url,
|
||||
repeatableTimes: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
toast.success('Audio generated successfully!');
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to generate audio');
|
||||
} finally {
|
||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: undefined}});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSection?.script, dispatch]);
|
||||
|
||||
const canPreview = sections.some(
|
||||
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
|
||||
@@ -259,65 +186,9 @@ const ListeningSettings: React.FC = () => {
|
||||
preview={preview}
|
||||
submitModule={submitListening}
|
||||
>
|
||||
<Dropdown
|
||||
title="Audio Context"
|
||||
module={currentModule}
|
||||
open={localSettings.isAudioContextOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.topic}
|
||||
/>
|
||||
</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>
|
||||
<ListeningComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
108
src/components/ExamEditor/SettingsEditor/reading/components.tsx
Normal file
108
src/components/ExamEditor/SettingsEditor/reading/components.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useCallback } from "react";
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import ExercisePicker from "../../ExercisePicker";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { LevelPart, ReadingPart } from "@/interfaces/exam";
|
||||
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
|
||||
interface Props {
|
||||
localSettings: ReadingSectionSettings | LevelSectionSettings;
|
||||
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
|
||||
currentSection: ReadingPart | LevelPart;
|
||||
generatePassageDisabled?: boolean;
|
||||
levelId?: number;
|
||||
level?: boolean;
|
||||
}
|
||||
|
||||
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => {
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const {
|
||||
focusedSection,
|
||||
difficulty,
|
||||
} = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const generatePassage = useCallback(() => {
|
||||
generate(
|
||||
levelId ? levelId : focusedSection,
|
||||
"reading",
|
||||
"passage",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
title: data.title,
|
||||
text: data.text
|
||||
}],
|
||||
level ? focusedSection : undefined,
|
||||
level
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
|
||||
|
||||
const onTopicChange = useCallback((readingTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ readingTopic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
title="Generate Passage"
|
||||
module="reading"
|
||||
open={localSettings.isPassageOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
||||
disabled={generatePassageDisabled}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.readingTopic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex self-end h-16 mb-1">
|
||||
<GenerateBtn
|
||||
module="reading"
|
||||
genType="passage"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generatePassage}
|
||||
level={level}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
title="Add Exercises"
|
||||
module="reading"
|
||||
open={localSettings.isReadingTopicOpean}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
|
||||
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
|
||||
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
|
||||
>
|
||||
<ExercisePicker
|
||||
module="reading"
|
||||
sectionId={levelId !== undefined ? levelId : focusedSection}
|
||||
difficulty={difficulty}
|
||||
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
|
||||
levelSectionId={focusedSection}
|
||||
level={level}
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadingComponents;
|
||||
@@ -1,12 +1,7 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import SettingsEditor from ".";
|
||||
import React from "react";
|
||||
import SettingsEditor from "..";
|
||||
import Option from "@/interfaces/option";
|
||||
import Dropdown from "./Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import ExercisePicker from "../Shared/ExercisePicker";
|
||||
import { generate } from "./Shared/Generate";
|
||||
import GenerateBtn from "./Shared/GenerateBtn";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import useSettingsState from "../../Hooks/useSettingsState";
|
||||
import { ReadingExam, ReadingPart } from "@/interfaces/exam";
|
||||
import { ReadingSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
@@ -16,6 +11,7 @@ import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import ReadingComponents from "./components";
|
||||
|
||||
const ReadingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -60,32 +56,6 @@ const ReadingSettings: React.FC = () => {
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
const generatePassage = useCallback(() => {
|
||||
generate(
|
||||
focusedSection,
|
||||
currentModule,
|
||||
"context",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.topic && { topic: localSettings.topic })
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
title: data.title,
|
||||
text: data.text
|
||||
}]
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.topic, difficulty, focusedSection]);
|
||||
|
||||
const onTopicChange = useCallback((topic: string) => {
|
||||
updateLocalAndScheduleGlobal({ topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
|
||||
const canPreviewOrSubmit = sections.some(
|
||||
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
|
||||
);
|
||||
@@ -161,49 +131,9 @@ const ReadingSettings: React.FC = () => {
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitReading}
|
||||
>
|
||||
<Dropdown
|
||||
title="Generate Passage"
|
||||
module={currentModule}
|
||||
open={localSettings.isPassageOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.topic}
|
||||
/>
|
||||
</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>
|
||||
<ReadingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
@@ -1,481 +0,0 @@
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
|
||||
import Option from "@/interfaces/option";
|
||||
import { useCallback, useState } from "react";
|
||||
import { generate } from "./Shared/Generate";
|
||||
import SettingsEditor from ".";
|
||||
import Dropdown from "./Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import GenerateBtn from "./Shared/GenerateBtn";
|
||||
import clsx from "clsx";
|
||||
import { FaFemale, FaMale, FaChevronDown } from "react-icons/fa";
|
||||
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { toast } from "react-toastify";
|
||||
import { generateVideos } from "./Shared/generateVideos";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { useRouter } from "next/router";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
|
||||
export interface Avatar {
|
||||
name: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
const SpeakingSettings: React.FC = () => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { title, currentModule, speakingAvatars, dispatch } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
|
||||
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection,
|
||||
);
|
||||
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Speaking Part 1",
|
||||
value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
|
||||
},
|
||||
{
|
||||
label: "Preset: Speaking Part 2",
|
||||
value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
|
||||
},
|
||||
{
|
||||
label: "Preset: Speaking Part 3",
|
||||
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
|
||||
}
|
||||
];
|
||||
|
||||
const generateScript = useCallback((sectionId: number) => {
|
||||
const queryParams: {
|
||||
difficulty: string;
|
||||
first_topic?: string;
|
||||
second_topic?: string;
|
||||
topic?: string;
|
||||
} = { difficulty };
|
||||
|
||||
if (sectionId === 1) {
|
||||
if (localSettings.topic) {
|
||||
queryParams['first_topic'] = localSettings.topic;
|
||||
}
|
||||
if (localSettings.secondTopic) {
|
||||
queryParams['second_topic'] = localSettings.secondTopic;
|
||||
}
|
||||
} else {
|
||||
if (localSettings.topic) {
|
||||
queryParams['topic'] = localSettings.topic;
|
||||
}
|
||||
}
|
||||
|
||||
generate(
|
||||
sectionId,
|
||||
currentModule,
|
||||
"context", // <- not really context but exercises is reserved for reading, listening and level
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams
|
||||
},
|
||||
(data: any) => {
|
||||
switch (sectionId) {
|
||||
case 1:
|
||||
return [{
|
||||
prompts: data.questions,
|
||||
first_topic: data.first_topic,
|
||||
second_topic: data.second_topic
|
||||
}];
|
||||
case 2:
|
||||
return [{
|
||||
topic: data.topic,
|
||||
question: data.question,
|
||||
prompts: data.prompts,
|
||||
suffix: data.suffix
|
||||
}];
|
||||
case 3:
|
||||
return [{
|
||||
title: data.topic,
|
||||
prompts: data.questions
|
||||
}];
|
||||
default:
|
||||
return [data];
|
||||
}
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings, difficulty]);
|
||||
|
||||
const onTopicChange = useCallback((topic: string) => {
|
||||
updateLocalAndScheduleGlobal({ topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const onSecondTopicChange = useCallback((topic: string) => {
|
||||
updateLocalAndScheduleGlobal({ secondTopic: topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const canPreviewOrSubmit = (() => {
|
||||
return sections.every((s) => {
|
||||
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
switch (section.type) {
|
||||
case 'speaking':
|
||||
return section.title !== '' &&
|
||||
section.text !== '' &&
|
||||
section.video_url !== '' &&
|
||||
section.prompts.every(prompt => prompt !== '');
|
||||
|
||||
case 'interactiveSpeaking':
|
||||
if ('first_title' in section && 'second_title' in section) {
|
||||
return section.first_title !== '' &&
|
||||
section.second_title !== '' &&
|
||||
section.prompts.every(prompt => prompt.video_url !== '') &&
|
||||
section.prompts.length > 2;
|
||||
}
|
||||
return section.title !== '' &&
|
||||
section.prompts.every(prompt => prompt.video_url !== '');
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
const canGenerate = section && (() => {
|
||||
switch (focusedSection) {
|
||||
case 1: {
|
||||
const currentSection = section as InteractiveSpeakingExercise;
|
||||
return currentSection.first_title !== "" &&
|
||||
currentSection.second_title !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2;
|
||||
}
|
||||
case 2: {
|
||||
const currentSection = section as SpeakingExercise;
|
||||
return currentSection.title !== "" &&
|
||||
currentSection.text !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt !== "");
|
||||
}
|
||||
case 3: {
|
||||
const currentSection = section as InteractiveSpeakingExercise;
|
||||
return currentSection.title !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt.text !== "");
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const generateVideoCallback = useCallback((sectionId: number) => {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "media" } })
|
||||
generateVideos(
|
||||
section as InteractiveSpeakingExercise | SpeakingExercise,
|
||||
sectionId,
|
||||
selectedAvatar,
|
||||
speakingAvatars
|
||||
).then((results) => {
|
||||
switch (sectionId) {
|
||||
case 1:
|
||||
case 3: {
|
||||
const interactiveSection = section as InteractiveSpeakingExercise;
|
||||
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
|
||||
...prompt,
|
||||
video_url: results[index].url || ''
|
||||
}));
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ prompts: updatedPrompts }] } })
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
if (results[0]?.url) {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: [{ video_url: results[0].url }] } })
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
toast.error("Failed to generate the video, try again later!")
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAvatar, section]);
|
||||
|
||||
|
||||
const submitSpeaking = async () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const urlMap = new Map<string, string>();
|
||||
|
||||
const sectionsWithVideos = sections.filter(s => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
if (exercise.type === "speaking") {
|
||||
return exercise.video_url !== "";
|
||||
}
|
||||
if (exercise.type === "interactiveSpeaking") {
|
||||
return exercise.prompts?.some(prompt => prompt.video_url !== "");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (sectionsWithVideos.length === 0) {
|
||||
toast.error('No video sections found in the exam! Please record or import videos.');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sectionsWithVideos.map(async (section) => {
|
||||
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
const response = await fetch(exercise.video_url);
|
||||
const blob = await response.blob();
|
||||
formData.append('file', blob, 'video.mp4');
|
||||
urlMap.set(`${section.sectionId}`, exercise.video_url);
|
||||
} else {
|
||||
await Promise.all(
|
||||
exercise.prompts.map(async (prompt, promptIndex) => {
|
||||
if (prompt.video_url) {
|
||||
const response = await fetch(prompt.video_url);
|
||||
const blob = await response.blob();
|
||||
formData.append('file', blob, 'video.mp4');
|
||||
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const response = await axios.post('/api/storage', formData, {
|
||||
params: {
|
||||
directory: 'speaking_videos'
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
const { urls } = response.data;
|
||||
|
||||
const exam: SpeakingExam = {
|
||||
exercises: sections.map((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
const videoIndex = Array.from(urlMap.entries())
|
||||
.findIndex(([key]) => key === `${s.sectionId}`);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
} else {
|
||||
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
|
||||
const videoIndex = Array.from(urlMap.entries())
|
||||
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
|
||||
|
||||
return {
|
||||
...prompt,
|
||||
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
prompts: updatedPrompts,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}
|
||||
}),
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/speaking', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
Array.from(urlMap.values()).forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
exercises: sections
|
||||
.filter((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
return exercise.video_url !== "";
|
||||
}
|
||||
|
||||
if (exercise.type === "interactiveSpeaking") {
|
||||
return exercise.prompts?.every(prompt => prompt.video_url !== "");
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
return {
|
||||
...exercise,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
} as SpeakingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=speaking", router)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Speaking ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="speaking"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitSpeaking}
|
||||
>
|
||||
<Dropdown
|
||||
title="Generate Script"
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
|
||||
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center")}>
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.topic}
|
||||
/>
|
||||
</div>
|
||||
{focusedSection === 1 &&
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onSecondTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.secondTopic}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
|
||||
<GenerateBtn
|
||||
module={currentModule}
|
||||
genType="context"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateScript}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
title="Generate Video"
|
||||
module={currentModule}
|
||||
open={localSettings.isGenerateAudioOpen}
|
||||
disabled={!canGenerate}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudioOpen: isOpen }, false)}
|
||||
>
|
||||
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<select
|
||||
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "") {
|
||||
setSelectedAvatar(null);
|
||||
} else {
|
||||
const [name, gender] = e.target.value.split("-");
|
||||
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
|
||||
if (avatar) setSelectedAvatar(avatar);
|
||||
}
|
||||
}}
|
||||
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select an avatar</option>
|
||||
{speakingAvatars.map((avatar) => (
|
||||
<option
|
||||
key={`${avatar.name}-${avatar.gender}`}
|
||||
value={`${avatar.name}-${avatar.gender}`}
|
||||
>
|
||||
{avatar.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-2.5 top-2.5 pointer-events-none">
|
||||
{selectedAvatar && (
|
||||
selectedAvatar.gender === 'male' ? (
|
||||
<FaMale className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FaFemale className="w-5 h-5 text-pink-500" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GenerateBtn
|
||||
module={currentModule}
|
||||
genType="media"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateVideoCallback}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakingSettings;
|
||||
268
src/components/ExamEditor/SettingsEditor/speaking/components.tsx
Normal file
268
src/components/ExamEditor/SettingsEditor/speaking/components.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import clsx from "clsx";
|
||||
import { FaFemale, FaMale } from "react-icons/fa";
|
||||
import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { toast } from "react-toastify";
|
||||
import { generateVideos } from "../Shared/generateVideos";
|
||||
import { Module } from "@/interfaces";
|
||||
|
||||
export interface Avatar {
|
||||
name: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
localSettings: SpeakingSectionSettings | LevelSectionSettings;
|
||||
updateLocalAndScheduleGlobal: (updates: Partial<SpeakingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
|
||||
section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart;
|
||||
level?: boolean;
|
||||
module?: Module;
|
||||
}
|
||||
|
||||
const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, section, level, module = "speaking" }) => {
|
||||
|
||||
const { currentModule, speakingAvatars, dispatch } = useExamEditorStore();
|
||||
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
|
||||
|
||||
const generateScript = useCallback((sectionId: number) => {
|
||||
const queryParams: {
|
||||
difficulty: string;
|
||||
first_topic?: string;
|
||||
second_topic?: string;
|
||||
topic?: string;
|
||||
} = { difficulty };
|
||||
|
||||
if (sectionId === 1) {
|
||||
if (localSettings.speakingTopic) {
|
||||
queryParams['first_topic'] = localSettings.speakingTopic;
|
||||
}
|
||||
if (localSettings.speakingSecondTopic) {
|
||||
queryParams['second_topic'] = localSettings.speakingSecondTopic;
|
||||
}
|
||||
} else {
|
||||
if (localSettings.speakingTopic) {
|
||||
queryParams['topic'] = localSettings.speakingTopic;
|
||||
}
|
||||
}
|
||||
|
||||
generate(
|
||||
sectionId,
|
||||
currentModule,
|
||||
"speakingScript",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams
|
||||
},
|
||||
(data: any) => {
|
||||
switch (sectionId) {
|
||||
case 1:
|
||||
return [{
|
||||
prompts: data.questions,
|
||||
first_topic: data.first_topic,
|
||||
second_topic: data.second_topic
|
||||
}];
|
||||
case 2:
|
||||
return [{
|
||||
topic: data.topic,
|
||||
question: data.question,
|
||||
prompts: data.prompts,
|
||||
suffix: data.suffix
|
||||
}];
|
||||
case 3:
|
||||
return [{
|
||||
title: data.topic,
|
||||
prompts: data.questions
|
||||
}];
|
||||
default:
|
||||
return [data];
|
||||
}
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings, difficulty]);
|
||||
|
||||
const onTopicChange = useCallback((speakingTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ speakingTopic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const onSecondTopicChange = useCallback((speakingSecondTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ speakingSecondTopic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
const canGenerate = section && (() => {
|
||||
switch (focusedSection) {
|
||||
case 1: {
|
||||
const currentSection = section as InteractiveSpeakingExercise;
|
||||
return currentSection.first_title !== "" &&
|
||||
currentSection.second_title !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt.text !== "") && currentSection.prompts.length > 2;
|
||||
}
|
||||
case 2: {
|
||||
const currentSection = section as SpeakingExercise;
|
||||
return currentSection.title !== "" &&
|
||||
currentSection.text !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt !== "");
|
||||
}
|
||||
case 3: {
|
||||
const currentSection = section as InteractiveSpeakingExercise;
|
||||
return currentSection.title !== "" &&
|
||||
currentSection.prompts.every(prompt => prompt.text !== "");
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const generateVideoCallback = useCallback((sectionId: number) => {
|
||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "video" } })
|
||||
generateVideos(
|
||||
section as InteractiveSpeakingExercise | SpeakingExercise,
|
||||
sectionId,
|
||||
selectedAvatar,
|
||||
speakingAvatars
|
||||
).then((results) => {
|
||||
switch (sectionId) {
|
||||
case 1:
|
||||
case 3: {
|
||||
const interactiveSection = section as InteractiveSpeakingExercise;
|
||||
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
|
||||
...prompt,
|
||||
video_url: results[index].url || ''
|
||||
}));
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, module: currentModule, field: "genResult", value:
|
||||
{ generating: "video", result: [{ prompts: updatedPrompts }], module: module }
|
||||
}
|
||||
})
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
if (results[0]?.url) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId, module: currentModule, field: "genResult", value:
|
||||
{ generating: "video", result: [{ video_url: results[0].url }], module: module }
|
||||
}
|
||||
})
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
toast.error("Failed to generate the video, try again later!")
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAvatar, section]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
title="Generate Script"
|
||||
module="speaking"
|
||||
open={localSettings.isSpeakingTopicOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingTopicOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
|
||||
>
|
||||
|
||||
<div className={clsx("gap-2 px-2 pb-4", focusedSection === 1 ? "flex flex-col w-full" : "flex flex-row items-center")}>
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">{`${focusedSection === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.speakingTopic}
|
||||
/>
|
||||
</div>
|
||||
{focusedSection === 1 &&
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onSecondTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.speakingSecondTopic}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
|
||||
<GenerateBtn
|
||||
module="speaking"
|
||||
genType="speakingScript"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateScript}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
title="Generate Video"
|
||||
module="speaking"
|
||||
open={localSettings.isGenerateVideoOpen}
|
||||
disabled={!canGenerate}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)}
|
||||
>
|
||||
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<select
|
||||
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "") {
|
||||
setSelectedAvatar(null);
|
||||
} else {
|
||||
const [name, gender] = e.target.value.split("-");
|
||||
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
|
||||
if (avatar) setSelectedAvatar(avatar);
|
||||
}
|
||||
}}
|
||||
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Select an avatar (Optional)</option>
|
||||
{speakingAvatars.map((avatar) => (
|
||||
<option
|
||||
key={`${avatar.name}-${avatar.gender}`}
|
||||
value={`${avatar.name}-${avatar.gender}`}
|
||||
>
|
||||
{avatar.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-2.5 top-2.5 pointer-events-none">
|
||||
{selectedAvatar && (
|
||||
selectedAvatar.gender === 'male' ? (
|
||||
<FaMale className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FaFemale className="w-5 h-5 text-pink-500" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GenerateBtn
|
||||
module="speaking"
|
||||
genType="video"
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generateVideoCallback}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakingComponents;
|
||||
261
src/components/ExamEditor/SettingsEditor/speaking/index.tsx
Normal file
261
src/components/ExamEditor/SettingsEditor/speaking/index.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import useSettingsState from "../../Hooks/useSettingsState";
|
||||
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
|
||||
import Option from "@/interfaces/option";
|
||||
import SettingsEditor from "..";
|
||||
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
|
||||
import { toast } from "react-toastify";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
import { useRouter } from "next/router";
|
||||
import openDetachedTab from "@/utils/popout";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import SpeakingComponents from "./components";
|
||||
|
||||
export interface Avatar {
|
||||
name: string;
|
||||
gender: string;
|
||||
}
|
||||
|
||||
const SpeakingSettings: React.FC = () => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
setExam,
|
||||
setExerciseIndex,
|
||||
setQuestionIndex,
|
||||
setBgColor,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { title, currentModule } = useExamEditorStore();
|
||||
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
|
||||
|
||||
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection,
|
||||
);
|
||||
|
||||
if (section === undefined) return <></>;
|
||||
|
||||
const currentSection = section as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
const defaultPresets: Option[] = [
|
||||
{
|
||||
label: "Preset: Speaking Part 1",
|
||||
value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
|
||||
},
|
||||
{
|
||||
label: "Preset: Speaking Part 2",
|
||||
value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
|
||||
},
|
||||
{
|
||||
label: "Preset: Speaking Part 3",
|
||||
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
|
||||
}
|
||||
];
|
||||
|
||||
const canPreviewOrSubmit = (() => {
|
||||
return sections.every((s) => {
|
||||
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
switch (section.type) {
|
||||
case 'speaking':
|
||||
return section.title !== '' &&
|
||||
section.text !== '' &&
|
||||
section.video_url !== '' &&
|
||||
section.prompts.every(prompt => prompt !== '');
|
||||
|
||||
case 'interactiveSpeaking':
|
||||
if ('first_title' in section && 'second_title' in section) {
|
||||
return section.first_title !== '' &&
|
||||
section.second_title !== '' &&
|
||||
section.prompts.every(prompt => prompt.video_url !== '') &&
|
||||
section.prompts.length > 2;
|
||||
}
|
||||
return section.title !== '' &&
|
||||
section.prompts.every(prompt => prompt.video_url !== '');
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
const submitSpeaking = async () => {
|
||||
if (title === "") {
|
||||
toast.error("Enter a title for the exam!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const urlMap = new Map<string, string>();
|
||||
|
||||
const sectionsWithVideos = sections.filter(s => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
if (exercise.type === "speaking") {
|
||||
return exercise.video_url !== "";
|
||||
}
|
||||
if (exercise.type === "interactiveSpeaking") {
|
||||
return exercise.prompts?.some(prompt => prompt.video_url !== "");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (sectionsWithVideos.length === 0) {
|
||||
toast.error('No video sections found in the exam! Please record or import videos.');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sectionsWithVideos.map(async (section) => {
|
||||
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
const response = await fetch(exercise.video_url);
|
||||
const blob = await response.blob();
|
||||
formData.append('file', blob, 'video.mp4');
|
||||
urlMap.set(`${section.sectionId}`, exercise.video_url);
|
||||
} else {
|
||||
await Promise.all(
|
||||
exercise.prompts.map(async (prompt, promptIndex) => {
|
||||
if (prompt.video_url) {
|
||||
const response = await fetch(prompt.video_url);
|
||||
const blob = await response.blob();
|
||||
formData.append('file', blob, 'video.mp4');
|
||||
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const response = await axios.post('/api/storage', formData, {
|
||||
params: {
|
||||
directory: 'speaking_videos'
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
|
||||
const { urls } = response.data;
|
||||
|
||||
const exam: SpeakingExam = {
|
||||
exercises: sections.map((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
const videoIndex = Array.from(urlMap.entries())
|
||||
.findIndex(([key]) => key === `${s.sectionId}`);
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
} else {
|
||||
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
|
||||
const videoIndex = Array.from(urlMap.entries())
|
||||
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
|
||||
|
||||
return {
|
||||
...prompt,
|
||||
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...exercise,
|
||||
prompts: updatedPrompts,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}
|
||||
}),
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
instructorGender: "varied",
|
||||
private: isPrivate,
|
||||
};
|
||||
|
||||
const result = await axios.post('/api/exam/speaking', exam);
|
||||
playSound("sent");
|
||||
toast.success(`Submitted Exam ID: ${result.data.id}`);
|
||||
|
||||
Array.from(urlMap.values()).forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
"Something went wrong while submitting, please try again later."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const preview = () => {
|
||||
setExam({
|
||||
exercises: sections
|
||||
.filter((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
|
||||
if (exercise.type === "speaking") {
|
||||
return exercise.video_url !== "";
|
||||
}
|
||||
|
||||
if (exercise.type === "interactiveSpeaking") {
|
||||
return exercise.prompts?.every(prompt => prompt.video_url !== "");
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map((s) => {
|
||||
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
|
||||
return {
|
||||
...exercise,
|
||||
intro: s.settings.currentIntro,
|
||||
category: s.settings.category
|
||||
};
|
||||
}),
|
||||
minTimer,
|
||||
module: "speaking",
|
||||
id: title,
|
||||
isDiagnostic: false,
|
||||
variant: undefined,
|
||||
difficulty,
|
||||
private: isPrivate,
|
||||
} as SpeakingExam);
|
||||
setExerciseIndex(0);
|
||||
setQuestionIndex(0);
|
||||
setBgColor("bg-white");
|
||||
openDetachedTab("popout?type=Exam&module=speaking", router)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsEditor
|
||||
sectionLabel={`Speaking ${focusedSection}`}
|
||||
sectionId={focusedSection}
|
||||
module="speaking"
|
||||
introPresets={[defaultPresets[focusedSection - 1]]}
|
||||
preview={preview}
|
||||
canPreview={canPreviewOrSubmit}
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitSpeaking}
|
||||
>
|
||||
<SpeakingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal, section: currentSection }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeakingSettings;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { LevelSectionSettings, WritingSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { WritingExercise } from "@/interfaces/exam";
|
||||
|
||||
|
||||
interface Props {
|
||||
localSettings: WritingSectionSettings | LevelSectionSettings;
|
||||
updateLocalAndScheduleGlobal: (updates: Partial<WritingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
|
||||
currentSection?: WritingExercise;
|
||||
level?: boolean;
|
||||
}
|
||||
|
||||
const WritingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, level}) => {
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const {
|
||||
difficulty,
|
||||
focusedSection,
|
||||
} = useExamEditorStore((store) => store.modules["writing"]);
|
||||
|
||||
const generatePassage = useCallback((sectionId: number) => {
|
||||
generate(
|
||||
sectionId,
|
||||
currentModule,
|
||||
"writing",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.writingTopic && { topic: localSettings.writingTopic })
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
prompt: data.question
|
||||
}]
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.writingTopic, difficulty]);
|
||||
|
||||
const onTopicChange = useCallback((writingTopic: string) => {
|
||||
updateLocalAndScheduleGlobal({ writingTopic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
title="Generate Instructions"
|
||||
module={"writing"}
|
||||
open={localSettings.isWritingTopicOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)}
|
||||
contentWrapperClassName={level ? `border border-ielts-writing`: ''}
|
||||
>
|
||||
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.writingTopic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex self-end h-16 mb-1">
|
||||
<GenerateBtn
|
||||
genType="writing"
|
||||
module={"writing"}
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generatePassage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WritingComponents;
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import SettingsEditor from ".";
|
||||
import SettingsEditor from "..";
|
||||
import Option from "@/interfaces/option";
|
||||
import Dropdown from "./Shared/SettingsDropdown";
|
||||
import Dropdown from "../Shared/SettingsDropdown";
|
||||
import Input from "@/components/Low/Input";
|
||||
import { generate } from "./Shared/Generate";
|
||||
import useSettingsState from "../Hooks/useSettingsState";
|
||||
import GenerateBtn from "./Shared/GenerateBtn";
|
||||
import { SectionSettings } from "@/stores/examEditor/types";
|
||||
import { generate } from "../Shared/Generate";
|
||||
import useSettingsState from "../../Hooks/useSettingsState";
|
||||
import GenerateBtn from "../Shared/GenerateBtn";
|
||||
import { WritingSectionSettings } from "@/stores/examEditor/types";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useRouter } from "next/router";
|
||||
import { usePersistentExamStore } from "@/stores/examStore";
|
||||
@@ -16,6 +16,7 @@ import { v4 } from "uuid";
|
||||
import axios from "axios";
|
||||
import { playSound } from "@/utils/sound";
|
||||
import { toast } from "react-toastify";
|
||||
import WritingComponents from "./components";
|
||||
|
||||
const WritingSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
@@ -37,7 +38,7 @@ const WritingSettings: React.FC = () => {
|
||||
setExerciseIndex,
|
||||
} = usePersistentExamStore();
|
||||
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
|
||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<WritingSectionSettings>(
|
||||
currentModule,
|
||||
focusedSection,
|
||||
);
|
||||
@@ -53,29 +54,6 @@ const WritingSettings: React.FC = () => {
|
||||
}
|
||||
];
|
||||
|
||||
const generatePassage = useCallback((sectionId: number) => {
|
||||
generate(
|
||||
sectionId,
|
||||
currentModule,
|
||||
"context",
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: {
|
||||
difficulty,
|
||||
...(localSettings.topic && { topic: localSettings.topic })
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
prompt: data.question
|
||||
}]
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSettings.topic, difficulty]);
|
||||
|
||||
const onTopicChange = useCallback((topic: string) => {
|
||||
updateLocalAndScheduleGlobal({ topic });
|
||||
}, [updateLocalAndScheduleGlobal]);
|
||||
|
||||
useEffect(() => {
|
||||
setCanPreviewOrSubmit(states.some((s) => s.prompt !== ""))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -145,36 +123,9 @@ const WritingSettings: React.FC = () => {
|
||||
canSubmit={canPreviewOrSubmit}
|
||||
submitModule={submitWriting}
|
||||
>
|
||||
<Dropdown
|
||||
title="Generate Instructions"
|
||||
module={currentModule}
|
||||
open={localSettings.isExerciseDropdownOpen}
|
||||
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isExerciseDropdownOpen: isOpen }, false)}
|
||||
>
|
||||
|
||||
<div className="flex flex-row gap-2 items-center px-2 pb-4">
|
||||
<div className="flex flex-col flex-grow gap-4 px-2">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
|
||||
<Input
|
||||
key={`section-${focusedSection}`}
|
||||
type="text"
|
||||
placeholder="Topic"
|
||||
name="category"
|
||||
onChange={onTopicChange}
|
||||
roundness="full"
|
||||
value={localSettings.topic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex self-end h-16 mb-1">
|
||||
<GenerateBtn
|
||||
genType="context"
|
||||
module={currentModule}
|
||||
sectionId={focusedSection}
|
||||
generateFnc={generatePassage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<WritingComponents
|
||||
{...{ localSettings, updateLocalAndScheduleGlobal }}
|
||||
/>
|
||||
</SettingsEditor>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user