Forgot to stage it
This commit is contained in:
@@ -0,0 +1,222 @@
|
|||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { BiQuestionMark } from 'react-icons/bi';
|
||||||
|
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
import GenLoader from "../Shared/GenLoader";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||||
|
import { BsFileText } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
exercise: InteractiveSpeakingExercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
|
||||||
|
const { generating, genResult } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
setEditing(false);
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
},
|
||||||
|
onMode: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (genResult && generating === "context") {
|
||||||
|
setEditing(true);
|
||||||
|
setLocal({
|
||||||
|
...local,
|
||||||
|
title: genResult[0].title,
|
||||||
|
prompts: genResult[0].prompts.map((item: any) => ({
|
||||||
|
text: item || "",
|
||||||
|
video_url: ""
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: {
|
||||||
|
sectionId,
|
||||||
|
module: currentModule,
|
||||||
|
field: "genResult",
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [genResult, generating]);
|
||||||
|
|
||||||
|
const addPrompt = () => {
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompts: [...prev.prompts, { text: "", video_url: "" }]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePrompt = (index: number) => {
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePrompt = (index: number, text: string) => {
|
||||||
|
setLocal(prev => {
|
||||||
|
const newPrompts = [...prev.prompts];
|
||||||
|
newPrompts[index] = { ...newPrompts[index], text };
|
||||||
|
return { ...prev, prompts: newPrompts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnedited = local.prompts.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='relative pb-4'>
|
||||||
|
<Header
|
||||||
|
title={`Interactive Speaking Script`}
|
||||||
|
description='Generate or write the scripts for the videos.'
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
mode="edit"
|
||||||
|
module="speaking"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{generating ? (
|
||||||
|
<GenLoader module={currentModule} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col py-2 mt-2">
|
||||||
|
<h2 className="font-semibold text-xl mb-2">Title</h2>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={local.title || ''}
|
||||||
|
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||||
|
placeholder="Enter the title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center mb-4 mt-6">
|
||||||
|
<h2 className="font-semibold text-xl">Questions</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{local.prompts.length === 0 ? (
|
||||||
|
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||||
|
<p className="text-gray-600">No questions added yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
local.prompts.map((prompt, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent>
|
||||||
|
<div className="bg-gray-50 rounded-lg pt-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||||
|
onClick={() => removePrompt(index)}
|
||||||
|
>
|
||||||
|
<AiOutlineDelete className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={prompt.text}
|
||||||
|
onChange={(text) => updatePrompt(index, text)}
|
||||||
|
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||||
|
placeholder={`Enter question ${index + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPrompt}
|
||||||
|
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||||
|
>
|
||||||
|
<AiOutlinePlus className="h-5 w-5" />
|
||||||
|
Add Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : isUnedited ? (
|
||||||
|
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||||
|
Generate or edit the questions!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<div className="flex flex-row mb-3 gap-4">
|
||||||
|
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Title</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||||
|
<p className="text-lg">{local.title || 'Untitled'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<div className="flex flex-row mb-3 gap-4">
|
||||||
|
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Questions</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
{local.prompts
|
||||||
|
.filter(prompt => prompt.text !== "")
|
||||||
|
.map((prompt, index) => (
|
||||||
|
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
|
||||||
|
<p className="text-gray-700">{prompt.text}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveSpeaking;
|
||||||
280
src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx
Normal file
280
src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
import GenLoader from "../Shared/GenLoader";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||||
|
import { BsFileText } from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
exercise: InteractiveSpeakingExercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Speaking1: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const [local, setLocal] = useState(() => {
|
||||||
|
const defaultPrompts = [
|
||||||
|
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
|
||||||
|
{ text: "Do you work or do you study?", video_url: "" },
|
||||||
|
...exercise.prompts.slice(2)
|
||||||
|
];
|
||||||
|
return { ...exercise, prompts: defaultPrompts };
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAvatarName = (avatarName: string) => {
|
||||||
|
setLocal(prev => {
|
||||||
|
const updatedPrompts = [...prev.prompts];
|
||||||
|
updatedPrompts[0] = {
|
||||||
|
...updatedPrompts[0],
|
||||||
|
text: updatedPrompts[0].text.replace("{avatar}", avatarName)
|
||||||
|
};
|
||||||
|
return { ...prev, prompts: updatedPrompts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { generating, genResult } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
setEditing(false);
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal({
|
||||||
|
...exercise,
|
||||||
|
prompts: [
|
||||||
|
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
|
||||||
|
{ text: "Do you work or do you study?", video_url: "" },
|
||||||
|
...exercise.prompts.slice(2)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onMode: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (genResult && generating === "context") {
|
||||||
|
setEditing(true);
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
first_title: genResult[0].first_topic,
|
||||||
|
second_title: genResult[0].second_topic,
|
||||||
|
prompts: [
|
||||||
|
prev.prompts[0],
|
||||||
|
prev.prompts[1],
|
||||||
|
...genResult[0].prompts.map((item: any) => ({
|
||||||
|
text: item,
|
||||||
|
video_url: ""
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: {
|
||||||
|
sectionId,
|
||||||
|
module: currentModule,
|
||||||
|
field: "genResult",
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [genResult, generating]);
|
||||||
|
|
||||||
|
const addPrompt = () => {
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompts: [...prev.prompts, { text: "", video_url: "" }]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePrompt = (index: number) => {
|
||||||
|
if (index < 2) return;
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePrompt = (index: number, text: string) => {
|
||||||
|
if (index < 2) return;
|
||||||
|
setLocal(prev => {
|
||||||
|
const newPrompts = [...prev.prompts];
|
||||||
|
newPrompts[index] = { ...newPrompts[index], text };
|
||||||
|
return { ...prev, prompts: newPrompts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnedited = local.prompts.length === 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='relative pb-4'>
|
||||||
|
<Header
|
||||||
|
title={`Speaking 1 Script`}
|
||||||
|
description='Generate or write the scripts for the videos.'
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
mode="edit"
|
||||||
|
module="speaking"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{generating ? (
|
||||||
|
<GenLoader module={currentModule} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="py-2 mt-2">
|
||||||
|
<div className="flex flex-row mb-3 gap-4">
|
||||||
|
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Titles</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 mt-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="font-semibold text-lg mb-2">First Title</h2>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={local.first_title || ''}
|
||||||
|
onChange={(text) => setLocal(prev => ({ ...prev, first_title: text }))}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||||
|
placeholder="Enter the first title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="font-semibold text-lg mb-2">Second Title</h2>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={local.second_title || ''}
|
||||||
|
onChange={(text) => setLocal(prev => ({ ...prev, second_title: text }))}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||||
|
placeholder="Enter the second title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between mb-4 mt-6">
|
||||||
|
<h2 className="font-semibold text-xl">Questions</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{local.prompts.length === 2 ? (
|
||||||
|
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||||
|
<p className="text-gray-600">No questions added yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
local.prompts.slice(2).map((prompt, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent>
|
||||||
|
<div className="bg-gray-50 rounded-lg pt-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||||
|
onClick={() => removePrompt(index + 2)}
|
||||||
|
>
|
||||||
|
<AiOutlineDelete className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={prompt.text}
|
||||||
|
onChange={(text) => updatePrompt(index + 2, text)}
|
||||||
|
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||||
|
placeholder={`Enter question ${index + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPrompt}
|
||||||
|
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||||
|
>
|
||||||
|
<AiOutlinePlus className="h-5 w-5" />
|
||||||
|
Add Question
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : isUnedited ? (
|
||||||
|
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||||
|
Generate or edit the questions!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<div className="flex flex-row mb-4 gap-4">
|
||||||
|
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Titles</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex gap-6 mt-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">First Title</h4>
|
||||||
|
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||||
|
<p className="text-lg">{local.first_title || 'No first title'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">Second Title</h4>
|
||||||
|
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||||
|
<p className="text-lg">{local.second_title || 'No second title'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<div className="flex flex-row mb-3 gap-4">
|
||||||
|
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Questions</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
{local.prompts.slice(2)
|
||||||
|
.filter(prompt => prompt.text !== "")
|
||||||
|
.map((prompt, index) => (
|
||||||
|
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
|
||||||
|
<p className="text-gray-700">{prompt.text}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Speaking1;
|
||||||
280
src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx
Normal file
280
src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||||
|
import { SpeakingExercise } from "@/interfaces/exam";
|
||||||
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
|
import Header from "../../Shared/Header";
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
import { BsFileText } from 'react-icons/bs';
|
||||||
|
import { AiOutlineUnorderedList } from 'react-icons/ai';
|
||||||
|
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
|
||||||
|
import GenLoader from "../Shared/GenLoader";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sectionId: number;
|
||||||
|
exercise: SpeakingExercise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Speaking2: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||||
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
const [local, setLocal] = useState(exercise);
|
||||||
|
|
||||||
|
const { generating, genResult } = useExamEditorStore(
|
||||||
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const updateTopic = (topic: string) => {
|
||||||
|
setLocal(prev => ({ ...prev, topic: topic }));
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { topic: topic } } });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
||||||
|
sectionId,
|
||||||
|
mode: "edit",
|
||||||
|
onSave: () => {
|
||||||
|
setEditing(false);
|
||||||
|
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: local } });
|
||||||
|
},
|
||||||
|
onDiscard: () => {
|
||||||
|
setLocal(exercise);
|
||||||
|
},
|
||||||
|
onMode: () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (genResult && generating === "context") {
|
||||||
|
setEditing(true);
|
||||||
|
setLocal({
|
||||||
|
...local,
|
||||||
|
title: genResult[0].topic,
|
||||||
|
text: genResult[0].question,
|
||||||
|
prompts: genResult[0].prompts
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||||
|
payload: {
|
||||||
|
sectionId,
|
||||||
|
module: currentModule,
|
||||||
|
field: "genResult",
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [genResult, generating]);
|
||||||
|
|
||||||
|
const addPrompt = () => {
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompts: [...prev.prompts, ""]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePrompt = (index: number) => {
|
||||||
|
setLocal(prev => ({
|
||||||
|
...prev,
|
||||||
|
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePrompt = (index: number, text: string) => {
|
||||||
|
setLocal(prev => {
|
||||||
|
const newPrompts = [...prev.prompts];
|
||||||
|
newPrompts[index] = text;
|
||||||
|
return { ...prev, prompts: newPrompts };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnedited = local.text === "" ||
|
||||||
|
(local.title === undefined || local.title === "") ||
|
||||||
|
local.prompts.length === 0;
|
||||||
|
|
||||||
|
const tooltipContent = `
|
||||||
|
<div class='p-2 max-w-xs'>
|
||||||
|
<p class='text-sm text-white'>
|
||||||
|
Prompts are guiding points that help candidates structure their talk. They typically include aspects like:
|
||||||
|
<ul class='list-disc pl-4 mt-1'>
|
||||||
|
<li>Describing what/who/where</li>
|
||||||
|
<li>Explaining why</li>
|
||||||
|
<li>Sharing feelings or preferences</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='relative pb-4'>
|
||||||
|
<Header
|
||||||
|
title={`Speaking ${sectionId} Script`}
|
||||||
|
description='Generate or write the script for the video.'
|
||||||
|
editing={editing}
|
||||||
|
handleSave={handleSave}
|
||||||
|
modeHandle={modeHandle}
|
||||||
|
handleDiscard={handleDiscard}
|
||||||
|
mode="edit"
|
||||||
|
module="speaking"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{generating ? (
|
||||||
|
<GenLoader module={currentModule} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col py-2 mt-2">
|
||||||
|
<h2 className="font-semibold text-xl mb-2">Title</h2>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={local.title || ''}
|
||||||
|
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||||
|
placeholder="Enter the topic"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col py-2 mt-2">
|
||||||
|
<h2 className="font-semibold text-xl mb-2">Question</h2>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={local.text}
|
||||||
|
onChange={(text) => setLocal(prev => ({ ...prev, text: text }))}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||||
|
placeholder="Enter the main question"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between mb-4 mt-6">
|
||||||
|
<h2 className="font-semibold text-xl">Prompts</h2>
|
||||||
|
<Tooltip id="prompt-tp" />
|
||||||
|
<a
|
||||||
|
data-tooltip-id="prompt-tp"
|
||||||
|
data-tooltip-html={tooltipContent}
|
||||||
|
className='ml-1 w-6 h-6 flex items-center justify-center rounded-full hover:bg-gray-200 border bg-gray-100'
|
||||||
|
>
|
||||||
|
<BiQuestionMark
|
||||||
|
className="w-5 h-5 text-gray-500"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{local.prompts.length === 0 ? (
|
||||||
|
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||||
|
<p className="text-gray-600">No prompts added yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
local.prompts.map((prompt, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent>
|
||||||
|
<div className="bg-gray-50 rounded-lg pt-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="font-medium text-gray-700">Prompt {index + 1}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||||
|
onClick={() => removePrompt(index)}
|
||||||
|
>
|
||||||
|
<AiOutlineDelete className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(text) => updatePrompt(index, text)}
|
||||||
|
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||||
|
placeholder={`Enter prompt ${index + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPrompt}
|
||||||
|
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||||
|
>
|
||||||
|
<AiOutlinePlus className="h-5 w-5" />
|
||||||
|
Add Prompt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : isUnedited ? (
|
||||||
|
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||||
|
Generate or edit the script!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<div className="flex flex-row mb-3 gap-4">
|
||||||
|
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Title</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||||
|
<p className="text-lg">{local.title || 'Untitled'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<div className="flex flex-row mb-3 gap-4">
|
||||||
|
<BiMessageRoundedDetail className="h-5 w-5 text-green-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Question</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||||
|
<p className="text-lg">{local.text || 'No question provided'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{local.prompts && local.prompts.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<div className="flex flex-row mb-3 gap-4">
|
||||||
|
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||||
|
<h3 className="font-semibold text-xl">Prompts</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full p-4 bg-gray-50 shadow-inner rounded-lg border border-gray-100">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{local.prompts.map((prompt, index) => (
|
||||||
|
<div key={index} className="px-4 py-3 bg-white shadow rounded-lg border border-gray-100">
|
||||||
|
<p className="text-gray-700">{prompt}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Speaking2;
|
||||||
@@ -7,6 +7,9 @@ import Header from "../../Shared/Header";
|
|||||||
import GenLoader from "../Shared/GenLoader";
|
import GenLoader from "../Shared/GenLoader";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import Speaking2 from "./Speaking2";
|
||||||
|
import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||||
|
import Speaking1 from "./Speaking1";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
@@ -14,176 +17,24 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
const Speaking: React.FC<Props> = ({ sectionId, exercise }) => {
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule } = useExamEditorStore();
|
||||||
const { generating, genResult } = useExamEditorStore(
|
const { state } = useExamEditorStore(
|
||||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||||
);
|
);
|
||||||
const { edit } = useExamEditorStore((store) => store.modules[currentModule]);
|
|
||||||
|
|
||||||
const [local, setLocal] = useState(exercise);
|
|
||||||
const [loading, setLoading] = useState(generating === "context");
|
|
||||||
const [questions, setQuestions] = useState(() => {
|
|
||||||
if (sectionId === 1) {
|
|
||||||
if ((exercise as SpeakingExercise).prompts.length > 0) {
|
|
||||||
return (exercise as SpeakingExercise).prompts;
|
|
||||||
}
|
|
||||||
return Array(5).fill("");
|
|
||||||
} else if (sectionId === 2) {
|
|
||||||
if ((exercise as SpeakingExercise).text && (exercise as SpeakingExercise).prompts.length > 0) {
|
|
||||||
return (exercise as SpeakingExercise).prompts;
|
|
||||||
}
|
|
||||||
return Array(3).fill("");
|
|
||||||
} else {
|
|
||||||
if ((exercise as InteractiveSpeakingExercise).prompts.length > 0) {
|
|
||||||
return (exercise as InteractiveSpeakingExercise).prompts?.map(p => p.text);
|
|
||||||
}
|
|
||||||
return Array(5).fill("");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateModule = useCallback((updates: Partial<ModuleState>) => {
|
|
||||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const { editing, setEditing, handleSave, handleDiscard, modeHandle } = useSectionEdit({
|
|
||||||
sectionId,
|
|
||||||
mode: "edit",
|
|
||||||
onSave: () => {
|
|
||||||
let newExercise;
|
|
||||||
if (sectionId === 1) {
|
|
||||||
newExercise = {
|
|
||||||
...local,
|
|
||||||
prompts: questions
|
|
||||||
} as SpeakingExercise;
|
|
||||||
} else if (sectionId === 2) {
|
|
||||||
newExercise = {
|
|
||||||
...local,
|
|
||||||
text: questions[0],
|
|
||||||
prompts: questions.slice(1),
|
|
||||||
} as SpeakingExercise;
|
|
||||||
} else {
|
|
||||||
newExercise = {
|
|
||||||
...local,
|
|
||||||
prompts: questions.map(text => ({
|
|
||||||
text,
|
|
||||||
video_url: (local as InteractiveSpeakingExercise).prompts?.[0]?.video_url || ""
|
|
||||||
}))
|
|
||||||
} as InteractiveSpeakingExercise;
|
|
||||||
}
|
|
||||||
setEditing(false);
|
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_SECTION_STATE",
|
|
||||||
payload: {
|
|
||||||
sectionId: sectionId,
|
|
||||||
update: newExercise
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onDiscard: () => {
|
|
||||||
setLocal(exercise);
|
|
||||||
},
|
|
||||||
onMode: () => {
|
|
||||||
setLocal(exercise);
|
|
||||||
if (sectionId === 1) {
|
|
||||||
setQuestions(Array(5).fill(""));
|
|
||||||
} else if (sectionId === 2) {
|
|
||||||
setQuestions(Array(3).fill(""));
|
|
||||||
} else {
|
|
||||||
setQuestions(Array(5).fill(""));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isLoading = generating === "context";
|
|
||||||
setLoading(isLoading);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
updateModule({ edit: Array.from(new Set([...edit, sectionId])) });
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [generating, sectionId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (genResult && generating === "context") {
|
|
||||||
setEditing(true);
|
|
||||||
if (sectionId === 1) {
|
|
||||||
setQuestions(genResult[0].questions);
|
|
||||||
} else if (sectionId === 2) {
|
|
||||||
setQuestions([genResult[0].question, ...genResult[0].prompts]);
|
|
||||||
} else {
|
|
||||||
setQuestions(genResult[0].questions);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
|
||||||
payload: {
|
|
||||||
sectionId,
|
|
||||||
module: currentModule,
|
|
||||||
field: "genResult",
|
|
||||||
value: undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [genResult, generating, dispatch, sectionId, setEditing, currentModule]);
|
|
||||||
|
|
||||||
const handleQuestionChange = (index: number, value: string) => {
|
|
||||||
setQuestions(prev => {
|
|
||||||
const newQuestions = [...prev];
|
|
||||||
newQuestions[index] = value;
|
|
||||||
return newQuestions;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getQuestionLabel = (index: number) => {
|
|
||||||
if (sectionId === 2 && index === 0) {
|
|
||||||
return "Main Question";
|
|
||||||
} else if (sectionId === 2) {
|
|
||||||
return `Prompt ${index}`;
|
|
||||||
} else {
|
|
||||||
return `Question ${index + 1}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='relative pb-4'>
|
<div className="mx-auto p-3 space-y-6">
|
||||||
<Header
|
<div className="p-4">
|
||||||
title={`Speaking ${sectionId} Script`}
|
<div className="flex flex-col space-y-6">
|
||||||
description='Generate or write the script for the video.'
|
{sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}
|
||||||
editing={editing}
|
{sectionId === 2 && <Speaking2 sectionId={sectionId} exercise={state as SpeakingExercise} />}
|
||||||
handleSave={handleSave}
|
{sectionId === 3 && <InteractiveSpeaking sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}
|
||||||
modeHandle={modeHandle}
|
</div>
|
||||||
handleDiscard={handleDiscard}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<GenLoader module={currentModule} />
|
|
||||||
) : (
|
|
||||||
<div className="mx-auto p-3 space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex flex-col space-y-6">
|
|
||||||
{questions.map((question: string, index: number) => (
|
|
||||||
<div key={index} className="flex flex-col">
|
|
||||||
<h2 className="font-semibold my-2">{getQuestionLabel(index)}</h2>
|
|
||||||
<AutoExpandingTextArea
|
|
||||||
value={question}
|
|
||||||
onChange={(text) => handleQuestionChange(index, text)}
|
|
||||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
|
||||||
placeholder={`Enter ${getQuestionLabel(index).toLowerCase()}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Speaking;
|
export default Speaking;
|
||||||
@@ -19,7 +19,6 @@ import { toast } from "react-toastify";
|
|||||||
|
|
||||||
const ListeningSettings: React.FC = () => {
|
const ListeningSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [audioLoading, setAudioLoading] = useState(false);
|
|
||||||
const { currentModule, title, dispatch } = useExamEditorStore();
|
const { currentModule, title, dispatch } = useExamEditorStore();
|
||||||
const {
|
const {
|
||||||
focusedSection,
|
focusedSection,
|
||||||
@@ -187,7 +186,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setAudioLoading(true);
|
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {sectionId, module: currentModule, field: "generating", value: "media"}});
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
'/api/exam/media/listening',
|
'/api/exam/media/listening',
|
||||||
body,
|
body,
|
||||||
@@ -223,7 +222,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error('Failed to generate audio');
|
toast.error('Failed to generate audio');
|
||||||
} finally {
|
} finally {
|
||||||
setAudioLoading(false);
|
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
|
||||||
@@ -303,7 +302,7 @@ const ListeningSettings: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<GenerateBtn
|
<GenerateBtn
|
||||||
module={currentModule}
|
module={currentModule}
|
||||||
genType="context"
|
genType="media"
|
||||||
sectionId={focusedSection}
|
sectionId={focusedSection}
|
||||||
generateFnc={generateAudio}
|
generateFnc={generateAudio}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
const SpeakingSettings: React.FC = () => {
|
const SpeakingSettings: React.FC = () => {
|
||||||
|
|
||||||
const { currentModule, dispatch } = useExamEditorStore();
|
const { currentModule } = useExamEditorStore();
|
||||||
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
|
const { focusedSection, difficulty } = useExamEditorStore((store) => store.modules[currentModule])
|
||||||
|
|
||||||
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
|
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
|
||||||
@@ -33,7 +33,6 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const generateScript = useCallback((sectionId: number) => {
|
const generateScript = useCallback((sectionId: number) => {
|
||||||
|
|
||||||
const queryParams: {
|
const queryParams: {
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
first_topic?: string;
|
first_topic?: string;
|
||||||
@@ -57,7 +56,7 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
generate(
|
generate(
|
||||||
sectionId,
|
sectionId,
|
||||||
currentModule,
|
currentModule,
|
||||||
"context",
|
"context", // <- not really context but exercises is reserved for reading, listening and level
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
queryParams
|
queryParams
|
||||||
@@ -66,9 +65,9 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
case 1:
|
case 1:
|
||||||
return [{
|
return [{
|
||||||
questions: data.questions,
|
prompts: data.questions,
|
||||||
firstTopic: data.first_topic,
|
first_topic: data.first_topic,
|
||||||
secondTopic: data.second_topic
|
second_topic: data.second_topic
|
||||||
}];
|
}];
|
||||||
case 2:
|
case 2:
|
||||||
return [{
|
return [{
|
||||||
@@ -79,8 +78,8 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
}];
|
}];
|
||||||
case 3:
|
case 3:
|
||||||
return [{
|
return [{
|
||||||
topic: data.topic,
|
title: data.topic,
|
||||||
questions: data.questions
|
prompts: data.questions
|
||||||
}];
|
}];
|
||||||
default:
|
default:
|
||||||
return [data];
|
return [data];
|
||||||
@@ -95,7 +94,7 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
}, [updateLocalAndScheduleGlobal]);
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
const onSecondTopicChange = useCallback((topic: string) => {
|
const onSecondTopicChange = useCallback((topic: string) => {
|
||||||
updateLocalAndScheduleGlobal({ });
|
updateLocalAndScheduleGlobal({ secondTopic: topic });
|
||||||
}, [updateLocalAndScheduleGlobal]);
|
}, [updateLocalAndScheduleGlobal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,6 +152,24 @@ const SpeakingSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
<Dropdown
|
||||||
|
title="Generate Video"
|
||||||
|
module={currentModule}
|
||||||
|
open={localSettings.isGenerateAudio}
|
||||||
|
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateAudio: isOpen }, false)}
|
||||||
|
>
|
||||||
|
<div className={clsx("gap-2 px-2 pb-4 flex flex-row items-center" )}>
|
||||||
|
|
||||||
|
<div className={clsx("flex h-16 mb-1", focusedSection === 1 ? "justify-center mt-4" : "self-end")}>
|
||||||
|
<GenerateBtn
|
||||||
|
module={currentModule}
|
||||||
|
genType="media"
|
||||||
|
sectionId={focusedSection}
|
||||||
|
generateFnc={generateScript}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
</SettingsEditor>
|
</SettingsEditor>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ export interface SpeakingExercise extends Section {
|
|||||||
evaluation?: SpeakingEvaluation;
|
evaluation?: SpeakingEvaluation;
|
||||||
}[];
|
}[];
|
||||||
topic?: string;
|
topic?: string;
|
||||||
script?: Script;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InteractiveSpeakingExercise extends Section {
|
export interface InteractiveSpeakingExercise extends Section {
|
||||||
@@ -216,7 +215,6 @@ export interface InteractiveSpeakingExercise extends Section {
|
|||||||
first_topic?: string;
|
first_topic?: string;
|
||||||
second_topic?: string;
|
second_topic?: string;
|
||||||
variant?: "initial" | "final";
|
variant?: "initial" | "final";
|
||||||
script?: Script;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FillBlanksMCOption {
|
export interface FillBlanksMCOption {
|
||||||
|
|||||||
21
src/pages/api/exam/avatars.ts
Normal file
21
src/pages/api/exam/avatars.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { withIronSessionApiRoute } from "iron-session/next";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export default withIronSessionApiRoute(handler, sessionOptions);
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") return get(req, res);
|
||||||
|
|
||||||
|
return res.status(404).json({ ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (!req.session.user) return res.status(401).json({ ok: false });
|
||||||
|
|
||||||
|
const result = await axios.get(`${process.env.BACKEND_URL}/speaking/avatars`, {
|
||||||
|
headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` },
|
||||||
|
});
|
||||||
|
res.status(200).json(result.data);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import { redirect, serialize } from "@/utils";
|
|||||||
import { requestUser } from "@/utils/api";
|
import { requestUser } from "@/utils/api";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam";
|
import { Exercise, InteractiveSpeakingExercise, ListeningPart, SpeakingExercise } from "@/interfaces/exam";
|
||||||
import { type } from "os";
|
import axios from "axios";
|
||||||
|
|
||||||
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
const user = await requestUser(req, res)
|
const user = await requestUser(req, res)
|
||||||
@@ -41,6 +41,17 @@ export default function Generation({ user }: { user: User; }) {
|
|||||||
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
|
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAvatars = async () => {
|
||||||
|
const response = await axios.get("/api/exam/avatars");
|
||||||
|
console.log(response.data);
|
||||||
|
updateRoot({ speakingAvatars: response.data });
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAvatars();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// media cleanup on unmount
|
// media cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -49,9 +60,11 @@ export default function Generation({ user }: { user: User; }) {
|
|||||||
const listeningPart = section.state as ListeningPart;
|
const listeningPart = section.state as ListeningPart;
|
||||||
if (listeningPart.audio?.source) {
|
if (listeningPart.audio?.source) {
|
||||||
URL.revokeObjectURL(listeningPart.audio.source);
|
URL.revokeObjectURL(listeningPart.audio.source);
|
||||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
dispatch({
|
||||||
sectionId: section.sectionId, module: "listening", field: "state", value: {...listeningPart, audio: undefined}
|
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||||
}})
|
sectionId: section.sectionId, module: "listening", field: "state", value: { ...listeningPart, audio: undefined }
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,20 +73,24 @@ export default function Generation({ user }: { user: User; }) {
|
|||||||
if (sectionState.type === 'speaking') {
|
if (sectionState.type === 'speaking') {
|
||||||
const speakingExercise = sectionState as SpeakingExercise;
|
const speakingExercise = sectionState as SpeakingExercise;
|
||||||
URL.revokeObjectURL(speakingExercise.video_url);
|
URL.revokeObjectURL(speakingExercise.video_url);
|
||||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
dispatch({
|
||||||
sectionId: section.sectionId, module: "listening", field: "state", value: {...speakingExercise, video_url: undefined}
|
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||||
}})
|
sectionId: section.sectionId, module: "listening", field: "state", value: { ...speakingExercise, video_url: undefined }
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (sectionState.type === 'interactiveSpeaking') {
|
if (sectionState.type === 'interactiveSpeaking') {
|
||||||
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
const interactiveSpeaking = sectionState as InteractiveSpeakingExercise;
|
||||||
interactiveSpeaking.prompts.forEach(prompt => {
|
interactiveSpeaking.prompts.forEach(prompt => {
|
||||||
URL.revokeObjectURL(prompt.video_url);
|
URL.revokeObjectURL(prompt.video_url);
|
||||||
});
|
});
|
||||||
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
dispatch({
|
||||||
sectionId: section.sectionId, module: "listening", field: "state", value: {
|
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||||
...interactiveSpeaking, prompts: interactiveSpeaking.prompts.map((p)=> ({...p, video_url: undefined}))
|
sectionId: section.sectionId, module: "listening", field: "state", value: {
|
||||||
|
...interactiveSpeaking, prompts: interactiveSpeaking.prompts.map((p) => ({ ...p, video_url: undefined }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}})
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const defaultSettings = (module: Module) => {
|
|||||||
isAudioContextOpen: false,
|
isAudioContextOpen: false,
|
||||||
isAudioGenerationOpen: false,
|
isAudioGenerationOpen: false,
|
||||||
}
|
}
|
||||||
|
case 'speaking':
|
||||||
|
return {
|
||||||
|
...baseSettings,
|
||||||
|
isGenerateAudio: false
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return baseSettings;
|
return baseSettings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const useExamEditorStore = create<
|
|||||||
title: "",
|
title: "",
|
||||||
globalEdit: [],
|
globalEdit: [],
|
||||||
currentModule: "reading",
|
currentModule: "reading",
|
||||||
|
speakingAvatars: [],
|
||||||
modules: {
|
modules: {
|
||||||
reading: defaultModuleSettings("reading", 60),
|
reading: defaultModuleSettings("reading", 60),
|
||||||
writing: defaultModuleSettings("writing", 60),
|
writing: defaultModuleSettings("writing", 60),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface SectionSettings {
|
|||||||
|
|
||||||
export interface SpeakingSectionSettings extends SectionSettings {
|
export interface SpeakingSectionSettings extends SectionSettings {
|
||||||
secondTopic?: string;
|
secondTopic?: string;
|
||||||
|
isGenerateAudio: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadingSectionSettings extends SectionSettings {
|
export interface ReadingSectionSettings extends SectionSettings {
|
||||||
@@ -62,9 +63,15 @@ export interface ModuleState {
|
|||||||
edit: number[];
|
edit: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Avatar {
|
||||||
|
name: string;
|
||||||
|
gender: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default interface ExamEditorStore {
|
export default interface ExamEditorStore {
|
||||||
title: string;
|
title: string;
|
||||||
currentModule: Module;
|
currentModule: Module;
|
||||||
|
speakingAvatars: Avatar[];
|
||||||
modules: {
|
modules: {
|
||||||
[K in Module]: ModuleState
|
[K in Module]: ModuleState
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user