Merged in feature/ExamGenRework (pull request #107)
Feature/ExamGenRework Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -78,6 +78,7 @@ export const UnderlineQuestion: React.FC<UnderlineQuestionProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
validateQuestion(question);
|
validateQuestion(question);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [question]);
|
}, [question]);
|
||||||
|
|
||||||
const handlePromptChange = (value: string) => {
|
const handlePromptChange = (value: string) => {
|
||||||
|
|||||||
86
src/components/ExamEditor/Exercises/Script/Message.tsx
Normal file
86
src/components/ExamEditor/Exercises/Script/Message.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FaEdit, FaFemale, FaMale } from "react-icons/fa";
|
||||||
|
import { FaTrash } from "react-icons/fa6";
|
||||||
|
import { ScriptLine } from ".";
|
||||||
|
|
||||||
|
interface MessageProps {
|
||||||
|
message: ScriptLine & { position: 'left' | 'right' };
|
||||||
|
color: string;
|
||||||
|
editing: boolean;
|
||||||
|
onEdit?: (text: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Message: React.FC<MessageProps> = ({ message, color, editing, onEdit, onDelete }) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editText, setEditText] = useState(message.text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-start gap-2 ${message.position === 'left' ? 'justify-start' : 'justify-end'}`}>
|
||||||
|
<div className="flex flex-col w-[50%]">
|
||||||
|
<div className={`flex items-center gap-2 ${message.position === 'right' && 'self-end'}`}>
|
||||||
|
{message.gender === 'male' ? (
|
||||||
|
<FaMale className="w-5 h-5 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<FaFemale className="w-5 h-5 text-pink-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{message.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-lg p-3 bg-${color}-100 relative group mt-1`}>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={editText}
|
||||||
|
onChange={setEditText}
|
||||||
|
placeholder="Edit message..."
|
||||||
|
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
onEdit?.(editText);
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 bg-red-500 rounded-md hover:bg-gray-100 transition-colors text-sm font-medium text-white"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap flex-grow">{message.text}</p>
|
||||||
|
{editing && (
|
||||||
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="p-1 rounded hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<FaEdit className="w-3.5 h-3.5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1 rounded hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<FaTrash className="w-3.5 h-3.5 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Message;
|
||||||
330
src/components/ExamEditor/Exercises/Script/index.tsx
Normal file
330
src/components/ExamEditor/Exercises/Script/index.tsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Script } from "@/interfaces/exam";
|
||||||
|
import Message from './Message';
|
||||||
|
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import Input from '@/components/Low/Input';
|
||||||
|
import { FaFemale, FaMale, FaPlus } from 'react-icons/fa';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export interface Speaker {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
gender: 'male' | 'female';
|
||||||
|
color: string;
|
||||||
|
position: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Gender = 'male' | 'female';
|
||||||
|
|
||||||
|
export interface ScriptLine {
|
||||||
|
name: string;
|
||||||
|
gender: Gender;
|
||||||
|
text: string;
|
||||||
|
voice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageWithPosition extends ScriptLine {
|
||||||
|
position: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: number;
|
||||||
|
editing?: boolean;
|
||||||
|
local: Script;
|
||||||
|
setLocal: (script: Script) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorOptions = [
|
||||||
|
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange',
|
||||||
|
'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
|
||||||
|
];
|
||||||
|
|
||||||
|
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
|
||||||
|
const isConversation = [1, 3].includes(section);
|
||||||
|
|
||||||
|
const [selectedSpeaker, setSelectedSpeaker] = useState<string>('');
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
|
||||||
|
const speakerCount = section === 1 ? 2 : 4;
|
||||||
|
|
||||||
|
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
|
||||||
|
const existingScript = local as ScriptLine[];
|
||||||
|
const existingSpeakers = new Set<string>();
|
||||||
|
const speakerGenders = new Map<string, 'male' | 'female'>();
|
||||||
|
|
||||||
|
if (Array.isArray(existingScript)) {
|
||||||
|
existingScript.forEach(line => {
|
||||||
|
existingSpeakers.add(line.name);
|
||||||
|
speakerGenders.set(line.name, line.gender.toLowerCase() === 'female' ? 'female' : 'male' as 'male' | 'female');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakerArray = Array.from(existingSpeakers);
|
||||||
|
const totalNeeded = Math.max(speakerCount, speakerArray.length);
|
||||||
|
|
||||||
|
return Array.from({ length: totalNeeded }, (_, index) => {
|
||||||
|
if (index < speakerArray.length) {
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
name: speakerArray[index],
|
||||||
|
gender: speakerGenders.get(speakerArray[index]) || 'male',
|
||||||
|
color: colorOptions[index],
|
||||||
|
position: index % 2 === 0 ? 'left' : 'right'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
name: '',
|
||||||
|
gender: 'male',
|
||||||
|
color: colorOptions[index],
|
||||||
|
position: index % 2 === 0 ? 'left' : 'right'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const speakerProperties = useMemo(() => {
|
||||||
|
return speakers.reduce((acc, speaker) => {
|
||||||
|
if (speaker.name) {
|
||||||
|
acc[speaker.name] = {
|
||||||
|
color: speaker.color,
|
||||||
|
gender: speaker.gender
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, { color: string; gender: 'male' | 'female' }>);
|
||||||
|
}, [speakers]);
|
||||||
|
|
||||||
|
const allSpeakersConfigured = useMemo(() => {
|
||||||
|
return speakers.every(speaker => speaker.name.trim() !== '');
|
||||||
|
}, [speakers]);
|
||||||
|
|
||||||
|
const updateSpeaker = (index: number, updates: Partial<Speaker>) => {
|
||||||
|
const updatedSpeakers = speakers.map((speaker, i) => {
|
||||||
|
if (i === index) {
|
||||||
|
return { ...speaker, ...updates };
|
||||||
|
}
|
||||||
|
return speaker;
|
||||||
|
});
|
||||||
|
setSpeakers(updatedSpeakers);
|
||||||
|
|
||||||
|
if (Array.isArray(local)) {
|
||||||
|
if ('name' in updates && speakers[index].name) {
|
||||||
|
const oldName = speakers[index].name;
|
||||||
|
const newName = updates.name || '';
|
||||||
|
const updatedScript = local.map(line => {
|
||||||
|
if (line.name === oldName) {
|
||||||
|
return { ...line, name: newName };
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
setLocal(updatedScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('gender' in updates && speakers[index].name && updates.gender) {
|
||||||
|
const name = speakers[index].name;
|
||||||
|
const newGender = updates.gender;
|
||||||
|
const updatedScript = local.map(line => {
|
||||||
|
if (line.name === name) {
|
||||||
|
return { ...line, gender: newGender };
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
setLocal(updatedScript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('name' in updates && speakers[index].name === selectedSpeaker) {
|
||||||
|
setSelectedSpeaker(updates.name || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const addMessage = () => {
|
||||||
|
if (!isConversation || !selectedSpeaker || !newMessage.trim()) return;
|
||||||
|
if (!Array.isArray(local)) return;
|
||||||
|
|
||||||
|
const speaker = speakers.find(s => s.name === selectedSpeaker);
|
||||||
|
if (!speaker) return;
|
||||||
|
|
||||||
|
const newLine: ScriptLine = {
|
||||||
|
name: selectedSpeaker,
|
||||||
|
gender: speaker.gender,
|
||||||
|
text: newMessage.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedScript = [...local, newLine];
|
||||||
|
setLocal(updatedScript);
|
||||||
|
setNewMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMessage = (index: number, newText: string) => {
|
||||||
|
if (!Array.isArray(local)) return;
|
||||||
|
|
||||||
|
const updatedScript = [...local];
|
||||||
|
updatedScript[index] = { ...updatedScript[index], text: newText };
|
||||||
|
setLocal(updatedScript);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMessage = (index: number) => {
|
||||||
|
if (!Array.isArray(local)) return;
|
||||||
|
|
||||||
|
const updatedScript = local.filter((_, i) => i !== index);
|
||||||
|
setLocal(updatedScript);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMonologue = (text: string) => {
|
||||||
|
setLocal(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = useMemo(() => {
|
||||||
|
if (typeof local === 'string' || !Array.isArray(local)) return [];
|
||||||
|
|
||||||
|
return local.reduce<MessageWithPosition[]>((acc, line, index) => {
|
||||||
|
const normalizedLine = {
|
||||||
|
...line,
|
||||||
|
gender: line.gender.toLowerCase() === 'female' ? 'female' : 'male'
|
||||||
|
} as ScriptLine;
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
acc.push({ ...normalizedLine, position: 'left' });
|
||||||
|
} else {
|
||||||
|
const prevMsg = acc[index - 1];
|
||||||
|
const position = line.name === prevMsg.name
|
||||||
|
? prevMsg.position
|
||||||
|
: (prevMsg.position === 'left' ? 'right' : 'left');
|
||||||
|
acc.push({ ...normalizedLine, position });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}, [local]);
|
||||||
|
|
||||||
|
if (!isConversation) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10">
|
||||||
|
<div className="w-full">
|
||||||
|
{editing ? (
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={local as string}
|
||||||
|
onChange={updateMonologue}
|
||||||
|
placeholder='Write the monologue here...'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
|
||||||
|
<span>{(local as string) || "Edit, generate or import your own audio."}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{editing && (
|
||||||
|
<div className="bg-white rounded-2xl p-6 shadow-inner border">
|
||||||
|
<h3 className="text-lg font-medium text-gray-700 mb-6">Edit Conversation</h3>
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
{speakers.map((speaker, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name=""
|
||||||
|
value={speaker.name}
|
||||||
|
onChange={(text) => updateSpeaker(index, { name: text })}
|
||||||
|
placeholder="Speaker name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[140px] relative">
|
||||||
|
<select
|
||||||
|
value={speaker.gender}
|
||||||
|
onChange={(e) => updateSpeaker(index, { gender: e.target.value as 'male' | 'female' })}
|
||||||
|
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="female">Female</option>
|
||||||
|
<option value="male">Male</option>
|
||||||
|
</select>
|
||||||
|
<div className="absolute right-3 top-2.5 pointer-events-none">
|
||||||
|
{speaker.gender === 'male' ? (
|
||||||
|
<FaMale className="w-5 h-5 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<FaFemale className="w-5 h-5 text-pink-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-[240px] flex flex-col gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedSpeaker}
|
||||||
|
onChange={(e) => setSelectedSpeaker(e.target.value)}
|
||||||
|
disabled={!allSpeakersConfigured}
|
||||||
|
className="w-full h-[42px] px-4 appearance-none border border-gray-200 rounded-full focus:ring-1 focus:ring-blue-500 focus:outline-none bg-white text-gray-700 text-base disabled:bg-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">Select Speaker ...</option>
|
||||||
|
{speakers.filter(s => s.name).map((speaker) => (
|
||||||
|
<option key={speaker.id} value={speaker.name}>
|
||||||
|
{speaker.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={addMessage}
|
||||||
|
disabled={!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured}
|
||||||
|
className={clsx(
|
||||||
|
"w-full h-[42px] rounded-lg flex items-center justify-center gap-2 transition-colors font-medium",
|
||||||
|
!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured
|
||||||
|
? 'bg-gray-100 text-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FaPlus className="w-4 h-4" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<AutoExpandingTextArea
|
||||||
|
value={newMessage}
|
||||||
|
onChange={setNewMessage}
|
||||||
|
placeholder={allSpeakersConfigured ? "Type your message..." : "Configure all speakers first"}
|
||||||
|
disabled={!allSpeakersConfigured}
|
||||||
|
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none disabled:bg-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((message, index) => {
|
||||||
|
const properties = speakerProperties[message.name];
|
||||||
|
if (!properties) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
key={index}
|
||||||
|
message={message}
|
||||||
|
color={properties.color}
|
||||||
|
editing={editing}
|
||||||
|
onEdit={(text: string) => updateMessage(index, text)}
|
||||||
|
onDelete={() => deleteMessage(index)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScriptEditor;
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { Script } from '@/interfaces/exam';
|
|
||||||
import { FaFemale, FaMale } from "react-icons/fa";
|
|
||||||
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
const colorOptions = [
|
|
||||||
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange',
|
|
||||||
'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Speaker {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
gender: 'male' | 'female';
|
|
||||||
color: string;
|
|
||||||
position: 'left' | 'right';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
script?: Script;
|
|
||||||
setScript: React.Dispatch<React.SetStateAction<Script | undefined>>;
|
|
||||||
editing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ScriptRender: React.FC<Props> = ({ script, setScript, editing = false }) => {
|
|
||||||
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
|
|
||||||
if (!script || typeof script === 'string') return [];
|
|
||||||
|
|
||||||
const uniqueSpeakers = new Map();
|
|
||||||
const usedColors = new Set();
|
|
||||||
let isLeft = true;
|
|
||||||
|
|
||||||
script.forEach((line, index) => {
|
|
||||||
if (!uniqueSpeakers.has(line.name)) {
|
|
||||||
const availableColors = colorOptions.filter(color => !usedColors.has(color));
|
|
||||||
if (availableColors.length === 0) {
|
|
||||||
usedColors.clear();
|
|
||||||
}
|
|
||||||
const randomColor = availableColors[Math.floor(Math.random() * availableColors.length)];
|
|
||||||
usedColors.add(randomColor);
|
|
||||||
|
|
||||||
uniqueSpeakers.set(line.name, {
|
|
||||||
id: index,
|
|
||||||
name: line.name,
|
|
||||||
gender: line.gender,
|
|
||||||
color: randomColor,
|
|
||||||
position: isLeft ? 'left' : 'right'
|
|
||||||
});
|
|
||||||
isLeft = !isLeft;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(uniqueSpeakers.values());
|
|
||||||
});
|
|
||||||
|
|
||||||
const speakerProperties = useMemo(() => {
|
|
||||||
return speakers.reduce((acc, speaker) => {
|
|
||||||
acc[speaker.name] = {
|
|
||||||
color: speaker.color,
|
|
||||||
position: speaker.position
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, { color: string; position: 'left' | 'right' }>);
|
|
||||||
}, [speakers]);
|
|
||||||
|
|
||||||
if (script === undefined) return null;
|
|
||||||
|
|
||||||
if (typeof script === 'string') {
|
|
||||||
return (
|
|
||||||
<div className="w-full px-4">
|
|
||||||
{editing ? (
|
|
||||||
<AutoExpandingTextArea
|
|
||||||
className="w-full p-3 border rounded bg-white"
|
|
||||||
value={script}
|
|
||||||
onChange={(text) => setScript(text)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-700 p-3 bg-gray-100 rounded-lg" dangerouslySetInnerHTML={{ __html: script.split("\n").join("<br>") }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateMessage = (index: number, newText: string) => {
|
|
||||||
setScript([
|
|
||||||
...script.slice(0, index),
|
|
||||||
{ ...script[index], text: newText },
|
|
||||||
...script.slice(index + 1)
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full px-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{script.map((line, index) => {
|
|
||||||
const { color, position } = speakerProperties[line.name];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex items-start gap-2 ${position === 'left' ? 'justify-start' : 'justify-end'}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col w-[50%]">
|
|
||||||
<div className={clsx('flex', position !== 'left' && 'self-end')}>
|
|
||||||
{line.gender === 'male' ? (
|
|
||||||
<FaMale className="w-5 h-5 text-blue-500 mb-1" />
|
|
||||||
) : (
|
|
||||||
<FaFemale className="w-5 h-5 text-pink-500 mb-1" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm mb-1">
|
|
||||||
{line.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={`rounded-lg p-3 bg-${color}-100`}>
|
|
||||||
{editing ? (
|
|
||||||
<AutoExpandingTextArea
|
|
||||||
className="w-full p-2 border rounded bg-white"
|
|
||||||
value={line.text}
|
|
||||||
onChange={(text) => updateMessage(index, text)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-700">{line.text}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ScriptRender;
|
|
||||||
@@ -32,6 +32,7 @@ const useSectionEdit = ({
|
|||||||
|
|
||||||
const handleEdit = useCallback(() => {
|
const handleEdit = useCallback(() => {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sectionId, setEditing, updateRoot]);
|
}, [sectionId, setEditing, updateRoot]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
@@ -41,17 +42,20 @@ const useSectionEdit = ({
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [setEditing, updateRoot, onSave, sectionId]);
|
}, [setEditing, updateRoot, onSave, sectionId]);
|
||||||
|
|
||||||
const handleDiscard = useCallback(() => {
|
const handleDiscard = useCallback(() => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
onDiscard?.();
|
onDiscard?.();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [setEditing, updateRoot, onDiscard, sectionId]);
|
}, [setEditing, updateRoot, onDiscard, sectionId]);
|
||||||
|
|
||||||
const modeHandle = useCallback(() => {
|
const modeHandle = useCallback(() => {
|
||||||
setEditing(!editing);
|
setEditing(!editing);
|
||||||
onMode?.();
|
onMode?.();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [setEditing, editing, updateRoot, onMode, sectionId]);
|
}, [setEditing, editing, updateRoot, onMode, sectionId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useEffect, useState } from "react";
|
|||||||
import { ListeningPart } from "@/interfaces/exam";
|
import { ListeningPart } from "@/interfaces/exam";
|
||||||
import SectionContext from ".";
|
import SectionContext from ".";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { FaFemale, FaMale } from "react-icons/fa";
|
|
||||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||||
import ScriptRender from "../../Exercises/Shared/Script";
|
import ScriptRender from "../../Exercises/Script";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import Dropdown from "@/components/Dropdown";
|
||||||
|
|
||||||
|
|
||||||
const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||||
@@ -15,50 +15,55 @@ const ListeningContext: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
);
|
);
|
||||||
const listeningPart = state as ListeningPart;
|
const listeningPart = state as ListeningPart;
|
||||||
|
|
||||||
const [script, setScript] = useState(listeningPart.script);
|
const [scriptLocal, setScriptLocal] = useState(listeningPart.script);
|
||||||
|
|
||||||
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
const { editing, handleSave, handleDiscard, modeHandle, setEditing } = useSectionEdit({
|
||||||
sectionId,
|
sectionId,
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
const newState = { ...listeningPart };
|
const newState = { ...listeningPart };
|
||||||
newState.script = script;
|
newState.script = scriptLocal;
|
||||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } })
|
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState } })
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
},
|
},
|
||||||
onDiscard: () => {
|
onDiscard: () => {
|
||||||
setScript(listeningPart.script);
|
setScriptLocal(listeningPart.script);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (genResult !== undefined && generating === "context") {
|
if (genResult !== undefined && generating === "context") {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setScript(genResult[0].script)
|
setScriptLocal(genResult[0].script);
|
||||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
|
||||||
|
|
||||||
const renderContent = (editing: boolean) => {
|
const renderContent = (editing: boolean) => {
|
||||||
|
if (scriptLocal === undefined) {
|
||||||
if (script === undefined && !editing) {
|
return (
|
||||||
return (<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
<Card>
|
||||||
Generate or import audio to add exercises!
|
<CardContent className="py-10">
|
||||||
</p>
|
<span>Edit, generate or import your own audio.</span>
|
||||||
)
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
|
||||||
<CardContent className="py-10">
|
<Dropdown
|
||||||
<ScriptRender
|
className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
|
||||||
script={script}
|
title="Conversation"
|
||||||
setScript={setScript}
|
contentWrapperClassName="rounded-xl"
|
||||||
editing={editing}
|
>
|
||||||
/>
|
<ScriptRender
|
||||||
</CardContent>
|
local={scriptLocal}
|
||||||
</Card>
|
setLocal={setScriptLocal}
|
||||||
|
section={sectionId}
|
||||||
|
editing={editing}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import SortableSection from "../../Shared/SortableSection";
|
import SortableSection from "../../Shared/SortableSection";
|
||||||
import getReadingQuestions from '../SectionExercises/reading';
|
import getReadingQuestions from '../SectionExercises/reading';
|
||||||
import { Exercise, LevelPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
import { Exercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
|
||||||
import { ReadingExercise } from "./types";
|
import ExerciseItem, { ReadingExercise } from "./types";
|
||||||
import Dropdown from "@/components/Dropdown";
|
import Dropdown from "@/components/Dropdown";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import Writing from "../../Exercises/Writing";
|
import Writing from "../../Exercises/Writing";
|
||||||
import Speaking from "../../Exercises/Speaking";
|
import Speaking from "../../Exercises/Speaking";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactElement, ReactNode, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
@@ -15,15 +15,18 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
UniqueIdentifier,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import GenLoader from "../../Exercises/Shared/GenLoader";
|
import GenLoader from "../../Exercises/Shared/GenLoader";
|
||||||
import { ExamPart } from "@/stores/examEditor/types";
|
import { ExamPart } from "@/stores/examEditor/types";
|
||||||
import getListeningItems from "./listening";
|
import getListeningItems from "./listening";
|
||||||
import getLevelQuestionItems from "./level";
|
import getLevelQuestionItems from "./level";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
|
||||||
export interface Props {
|
interface QuestionItemsResult {
|
||||||
sectionId: number;
|
ids: string[];
|
||||||
|
items: ExerciseItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
||||||
@@ -49,7 +52,7 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
})
|
})
|
||||||
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [genResult, dispatch, sectionId, currentModule]);
|
}, [genResult, dispatch, sectionId, currentModule]);
|
||||||
|
|
||||||
const currentSection = sections.find((s) => s.sectionId === sectionId)!;
|
const currentSection = sections.find((s) => s.sectionId === sectionId)!;
|
||||||
@@ -58,27 +61,44 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
);
|
);
|
||||||
|
|
||||||
const questionItems = () => {
|
const questionItems = (): QuestionItemsResult => {
|
||||||
let ids, items;
|
let result: QuestionItemsResult = {
|
||||||
|
ids: [],
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
|
||||||
switch (currentModule) {
|
switch (currentModule) {
|
||||||
case "reading":
|
case "reading": {
|
||||||
items = getReadingQuestions((currentSection.state as ReadingPart).exercises as ReadingExercise[], sectionId);
|
const items = getReadingQuestions(
|
||||||
ids = items.map(q => q.id.toString());
|
(currentSection.state as ReadingPart).exercises as ReadingExercise[],
|
||||||
|
sectionId
|
||||||
|
);
|
||||||
|
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
|
||||||
|
result.ids = result.items.map(item => item.id);
|
||||||
break;
|
break;
|
||||||
case "listening":
|
}
|
||||||
items = getListeningItems((currentSection.state as ReadingPart).exercises as ReadingExercise[], sectionId);
|
case "listening": {
|
||||||
ids = items.map(q => q?.id.toString());
|
const items = getListeningItems(
|
||||||
|
(currentSection.state as ListeningPart).exercises as Exercise[],
|
||||||
|
sectionId
|
||||||
|
);
|
||||||
|
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
|
||||||
|
result.ids = result.items.map(item => item.id);
|
||||||
break;
|
break;
|
||||||
case "level":
|
}
|
||||||
items = getLevelQuestionItems((currentSection.state as LevelPart).exercises as Exercise[], sectionId);
|
case "level": {
|
||||||
ids = items.map(q => q.id.toString());
|
const items = getLevelQuestionItems(
|
||||||
|
(currentSection.state as LevelPart).exercises as Exercise[],
|
||||||
|
sectionId
|
||||||
|
);
|
||||||
|
result.items = items.filter((item): item is ExerciseItem => item !== undefined);
|
||||||
|
result.ids = result.items.map(item => item.id);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ids, items }
|
return result;
|
||||||
}
|
};
|
||||||
|
|
||||||
const questions = questionItems();
|
|
||||||
|
|
||||||
const background = (component: ReactNode) => {
|
const background = (component: ReactNode) => {
|
||||||
return (
|
return (
|
||||||
@@ -91,6 +111,20 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} />);
|
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} />);
|
||||||
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} />);
|
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} />);
|
||||||
|
|
||||||
|
const questions = questionItems();
|
||||||
|
|
||||||
|
const filteredIds = (questions.ids ?? []).filter(Boolean);
|
||||||
|
|
||||||
|
function isValidItem(item: ExerciseItem | undefined): item is ExerciseItem {
|
||||||
|
return item !== undefined &&
|
||||||
|
typeof item.id === 'string' &&
|
||||||
|
typeof item.sectionId === 'number' &&
|
||||||
|
React.isValidElement(item.label) &&
|
||||||
|
React.isValidElement(item.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredItems = (questions.items ?? []).filter(isValidItem);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -107,11 +141,11 @@ const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
|
|||||||
questions.ids.length > 0 && (
|
questions.ids.length > 0 && (
|
||||||
<div className="mt-4 p-6 rounded-xl shadow-inner border bg-gray-50">
|
<div className="mt-4 p-6 rounded-xl shadow-inner border bg-gray-50">
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={questions.ids}
|
items={filteredIds}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
{questions.items.map(item => (
|
{filteredItems.map(item => (
|
||||||
<SortableSection key={item.id.toString()} id={item.id.toString()}>
|
<SortableSection key={item.id} id={item.id}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
|
className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
|
||||||
customTitle={item.label}
|
customTitle={item.label}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Exercise } from "@/interfaces/exam";
|
import { Exercise } from "@/interfaces/exam";
|
||||||
import ExerciseItem from "./types";
|
import ExerciseItem, { isExerciseItem } from "./types";
|
||||||
import ExerciseLabel from "../../Shared/ExerciseLabel";
|
import ExerciseLabel from "../../Shared/ExerciseLabel";
|
||||||
import MultipleChoice from "../../Exercises/MultipleChoice";
|
import MultipleChoice from "../../Exercises/MultipleChoice";
|
||||||
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
|
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
|
||||||
@@ -17,7 +17,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
|||||||
firstWordId = exercise.questions[0].id;
|
firstWordId = exercise.questions[0].id;
|
||||||
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -35,7 +35,7 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
|||||||
firstWordId = exercise.solutions[0].id;
|
firstWordId = exercise.solutions[0].id;
|
||||||
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -52,9 +52,9 @@ const getLevelQuestionItems = (exercises: Exercise[], sectionId: number): Exerci
|
|||||||
default:
|
default:
|
||||||
return {} as unknown as ExerciseItem;
|
return {} as unknown as ExerciseItem;
|
||||||
}
|
}
|
||||||
}).filter((item) => item !== undefined);
|
}).filter(isExerciseItem);
|
||||||
|
|
||||||
return items || [];
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExerciseItem from './types';
|
import ExerciseItem, { isExerciseItem } from './types';
|
||||||
import ExerciseLabel from '../../Shared/ExerciseLabel';
|
import ExerciseLabel from '../../Shared/ExerciseLabel';
|
||||||
import FillBlanksLetters from '../../Exercises/Blanks/Letters';
|
import FillBlanksLetters from '../../Exercises/Blanks/Letters';
|
||||||
import { Exercise, WriteBlanksExercise } from '@/interfaces/exam';
|
import { Exercise, WriteBlanksExercise } from '@/interfaces/exam';
|
||||||
@@ -7,15 +7,14 @@ import WriteBlanksForm from '../../Exercises/WriteBlanksForm';
|
|||||||
import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill';
|
import WriteBlanksFill from '../../Exercises/Blanks/WriteBlankFill';
|
||||||
import WriteBlanks from '../../Exercises/WriteBlanks';
|
import WriteBlanks from '../../Exercises/WriteBlanks';
|
||||||
|
|
||||||
|
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string): ExerciseItem => {
|
||||||
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number, previewLabel: (text: string) => string) => {
|
|
||||||
const firstWordId = exercise.solutions[0].id;
|
const firstWordId = exercise.solutions[0].id;
|
||||||
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
|
|
||||||
switch (exercise.variant) {
|
switch (exercise.variant) {
|
||||||
case 'form':
|
case 'form':
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -31,7 +30,7 @@ const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: nu
|
|||||||
};
|
};
|
||||||
case 'fill':
|
case 'fill':
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -47,7 +46,7 @@ const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: nu
|
|||||||
};
|
};
|
||||||
case 'questions':
|
case 'questions':
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -62,23 +61,23 @@ const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: nu
|
|||||||
content: <WriteBlanks exercise={exercise} sectionId={sectionId} title='Write Blanks: Questions' />
|
content: <WriteBlanks exercise={exercise} sectionId={sectionId} title='Write Blanks: Questions' />
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
throw new Error(`Just so that typescript doesnt complain`);
|
||||||
|
};
|
||||||
|
|
||||||
const getListeningItems = (exercises: Exercise[], sectionId: number) => {
|
|
||||||
|
|
||||||
|
const getListeningItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
|
||||||
const previewLabel = (text: string) => {
|
const previewLabel = (text: string) => {
|
||||||
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : "";
|
return text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : "";
|
||||||
}
|
};
|
||||||
|
|
||||||
const items = exercises.map((exercise, index) => {
|
const mappedItems = exercises.map((exercise, index): ExerciseItem | null => {
|
||||||
let firstWordId, lastWordId;
|
let firstWordId, lastWordId;
|
||||||
|
|
||||||
switch (exercise.type) {
|
switch (exercise.type) {
|
||||||
case "fillBlanks":
|
case "fillBlanks":
|
||||||
firstWordId = exercise.solutions[0].id;
|
firstWordId = exercise.solutions[0].id;
|
||||||
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -91,15 +90,16 @@ const getListeningItems = (exercises: Exercise[], sectionId: number) => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
|
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
case "writeBlanks":
|
case "writeBlanks":
|
||||||
return writeBlanks(exercise, index, sectionId, previewLabel);
|
return writeBlanks(exercise, index, sectionId, previewLabel);
|
||||||
|
|
||||||
case "multipleChoice":
|
case "multipleChoice":
|
||||||
firstWordId = exercise.questions[0].id;
|
firstWordId = exercise.questions[0].id;
|
||||||
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -113,11 +113,15 @@ const getListeningItems = (exercises: Exercise[], sectionId: number) => {
|
|||||||
),
|
),
|
||||||
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}).filter((item) => item !== undefined);
|
|
||||||
|
|
||||||
return items || [];
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return mappedItems.filter((item): item is ExerciseItem =>
|
||||||
|
item !== null && isExerciseItem(item)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default getListeningItems;
|
export default getListeningItems;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import ExerciseItem, { ReadingExercise } from './types';
|
import ExerciseItem, { isExerciseItem, ReadingExercise } from './types';
|
||||||
import WriteBlanks from "@/editor/Exercises/WriteBlanks";
|
import WriteBlanks from "@/editor/Exercises/WriteBlanks";
|
||||||
import ExerciseLabel from '../../Shared/ExerciseLabel';
|
import ExerciseLabel from '../../Shared/ExerciseLabel';
|
||||||
import MatchSentences from '../../Exercises/MatchSentences';
|
import MatchSentences from '../../Exercises/MatchSentences';
|
||||||
@@ -19,7 +19,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
|
|||||||
firstWordId = exercise.solutions[0].id;
|
firstWordId = exercise.solutions[0].id;
|
||||||
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -37,7 +37,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
|
|||||||
firstWordId = exercise.solutions[0].id;
|
firstWordId = exercise.solutions[0].id;
|
||||||
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -55,7 +55,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
|
|||||||
firstWordId = exercise.sentences[0].id;
|
firstWordId = exercise.sentences[0].id;
|
||||||
lastWordId = exercise.sentences[exercise.sentences.length - 1].id;
|
lastWordId = exercise.sentences[exercise.sentences.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -73,7 +73,7 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
|
|||||||
firstWordId = exercise.questions[0].id
|
firstWordId = exercise.questions[0].id
|
||||||
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
lastWordId = exercise.questions[exercise.questions.length - 1].id;
|
||||||
return {
|
return {
|
||||||
id: index,
|
id: index.toString(),
|
||||||
sectionId,
|
sectionId,
|
||||||
label: (
|
label: (
|
||||||
<ExerciseLabel
|
<ExerciseLabel
|
||||||
@@ -89,9 +89,9 @@ const getExerciseItems = (exercises: ReadingExercise[], sectionId: number): Exer
|
|||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
}).filter((item) => item !== undefined);
|
}).filter(isExerciseItem);
|
||||||
|
|
||||||
return items || [];
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
import { FillBlanksExercise, MatchSentencesExercise, TrueFalseExercise, WriteBlanksExercise } from "@/interfaces/exam";
|
||||||
|
|
||||||
export default interface ExerciseItem {
|
export default interface ExerciseItem {
|
||||||
id: number;
|
id: string;
|
||||||
sectionId: number;
|
sectionId: number;
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise;
|
export type ReadingExercise = FillBlanksExercise | TrueFalseExercise | MatchSentencesExercise | WriteBlanksExercise;
|
||||||
|
|
||||||
|
export function isExerciseItem(item: unknown): item is ExerciseItem {
|
||||||
|
return item !== undefined &&
|
||||||
|
item !== null &&
|
||||||
|
typeof (item as ExerciseItem).id === 'string' &&
|
||||||
|
typeof (item as ExerciseItem).sectionId === 'number' &&
|
||||||
|
(item as ExerciseItem).label !== undefined &&
|
||||||
|
(item as ExerciseItem).content !== undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
|
|||||||
import { generate } from "../../SettingsEditor/Shared/Generate";
|
import { generate } from "../../SettingsEditor/Shared/Generate";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import { Dialog, ListeningPart, ReadingPart } from "@/interfaces/exam";
|
import { ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
|
||||||
|
|
||||||
interface ExercisePickerProps {
|
interface ExercisePickerProps {
|
||||||
module: string;
|
module: string;
|
||||||
@@ -81,7 +81,7 @@ const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
|||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
case 1:
|
case 1:
|
||||||
case 3:
|
case 3:
|
||||||
dialog = script as Dialog[];
|
dialog = script as Message[];
|
||||||
text = dialog.map((d) => `${d.name}: ${d.text}`).join("\n");
|
text = dialog.map((d) => `${d.name}: ${d.text}`).join("\n");
|
||||||
context = { text: text }
|
context = { text: text }
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ReadingPart } from '@/interfaces/exam';
|
|||||||
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
|
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
|
||||||
|
|
||||||
const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
||||||
const {currentModule, dispatch} = useExamEditorStore();
|
const { currentModule, dispatch } = useExamEditorStore();
|
||||||
|
|
||||||
const examInputRef = useRef<HTMLInputElement>(null);
|
const examInputRef = useRef<HTMLInputElement>(null);
|
||||||
const solutionsInputRef = useRef<HTMLInputElement>(null);
|
const solutionsInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -38,14 +38,14 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = useCallback( async () => {
|
const handleImport = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!examFile) {
|
if (!examFile) {
|
||||||
toast.error('Exam file is required');
|
toast.error('Exam file is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({type: "UPDATE_MODULE", payload: {updates: {importing: true}, module}})
|
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: true }, module } })
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('exercises', examFile);
|
formData.append('exercises', examFile);
|
||||||
@@ -76,27 +76,30 @@ const WordUploader: React.FC<{ module: Module }> = ({ module }) => {
|
|||||||
const newSectionsStates = data.parts.map(
|
const newSectionsStates = data.parts.map(
|
||||||
(part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part)
|
(part: ReadingPart, index: number) => defaultSectionSettings(module, index + 1, part)
|
||||||
);
|
);
|
||||||
dispatch({type: "UPDATE_MODULE", payload: {
|
dispatch({
|
||||||
updates: {
|
type: "UPDATE_MODULE", payload: {
|
||||||
sections: newSectionsStates,
|
updates: {
|
||||||
minTimer: data.minTimer,
|
sections: newSectionsStates,
|
||||||
importModule: false,
|
minTimer: data.minTimer,
|
||||||
importing: false,
|
importModule: false,
|
||||||
},
|
importing: false,
|
||||||
module
|
},
|
||||||
}});
|
module
|
||||||
|
}
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`An unknown error has occured while import ${module} exam!`);
|
toast.error(`An unknown error has occured while import ${module} exam!`);
|
||||||
} finally {
|
} finally {
|
||||||
dispatch({type: "UPDATE_MODULE", payload: {updates: {importing: false}, module}})
|
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: false }, module } })
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
examFile,
|
examFile,
|
||||||
solutionsFile,
|
solutionsFile,
|
||||||
dispatch,
|
dispatch,
|
||||||
module
|
currentModule
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { capitalize } from "lodash";
|
|||||||
import { Difficulty } from "@/interfaces/exam";
|
import { Difficulty } from "@/interfaces/exam";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { ModuleState } from "@/stores/examEditor/types";
|
import { ModuleState, SectionState } from "@/stores/examEditor/types";
|
||||||
import { Module } from "@/interfaces";
|
import { Module } from "@/interfaces";
|
||||||
import useExamEditorStore from "@/stores/examEditor";
|
import useExamEditorStore from "@/stores/examEditor";
|
||||||
import WritingSettings from "./SettingsEditor/writing";
|
import WritingSettings from "./SettingsEditor/writing";
|
||||||
@@ -38,8 +38,8 @@ const ExamEditor: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentSections = sections;
|
const currentSections = sections;
|
||||||
const currentLabels = sectionLabels;
|
const currentLabels = sectionLabels;
|
||||||
let updatedSections;
|
let updatedSections: SectionState[];
|
||||||
let updatedLabels;
|
let updatedLabels: any;
|
||||||
|
|
||||||
if (numberOfParts > currentSections.length) {
|
if (numberOfParts > currentSections.length) {
|
||||||
const newSections = [...currentSections];
|
const newSections = [...currentSections];
|
||||||
@@ -76,6 +76,7 @@ const ExamEditor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [numberOfParts]);
|
}, [numberOfParts]);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface Props {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const AutoExpandingTextArea: React.FC<Props> = ({
|
|||||||
value,
|
value,
|
||||||
className = 'w-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl',
|
className = 'w-full cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl',
|
||||||
placeholder = "Enter text here...",
|
placeholder = "Enter text here...",
|
||||||
|
disabled = false,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -45,6 +47,7 @@ const AutoExpandingTextArea: React.FC<Props> = ({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
style={{ overflow: 'hidden', resize: 'none' }}
|
style={{ overflow: 'hidden', resize: 'none' }}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
|
disabled={disabled}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
|
|||||||
{userSolutions &&
|
{userSolutions &&
|
||||||
questions.map((question, index) => {
|
questions.map((question, index) => {
|
||||||
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
|
||||||
const solution = question.solution.toString().toLowerCase() as Solution;
|
const solution = question?.solution?.toString().toLowerCase() as Solution;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={question.id.toString()} className="flex flex-col gap-4">
|
<div key={question.id.toString()} className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface Props {
|
|||||||
|
|
||||||
const UserDisplay = (displayUser: User) => (
|
const UserDisplay = (displayUser: User) => (
|
||||||
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
|
||||||
<div className="flex flex-col gap-1 items-start">
|
<div className="flex flex-col gap-1 items-start">
|
||||||
<span>{displayUser.name}</span>
|
<span>{displayUser.name}</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
FilterFn,
|
FilterFn,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { UserImport } from "./IUserImport";
|
import { UserImport } from "../interfaces/IUserImport";
|
||||||
|
|
||||||
const globalFilterFn: FilterFn<any> = (row, columnId, filterValue: string) => {
|
const globalFilterFn: FilterFn<any> = (row, columnId, filterValue: string) => {
|
||||||
const value = row.getValue(columnId);
|
const value = row.getValue(columnId);
|
||||||
@@ -198,23 +198,27 @@ export default function Listening({ exam, showSolutions = false, preview = false
|
|||||||
|
|
||||||
const renderAudioPlayer = () => (
|
const renderAudioPlayer = () => (
|
||||||
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
|
||||||
{exam.parts[partIndex].audio ? (
|
{exam?.parts[partIndex]?.audio?.source ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
|
||||||
<span className="text-base">
|
<span className="text-base">
|
||||||
{exam.parts[partIndex].audio.repeatableTimes > 0
|
{(() => {
|
||||||
? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).`
|
const audioRepeatTimes = exam?.parts[partIndex]?.audio?.repeatableTimes;
|
||||||
: "You may listen to the audio as many times as you would like."}
|
return audioRepeatTimes && audioRepeatTimes > 0
|
||||||
|
? `You will only be allowed to listen to the audio ${audioRepeatTimes - timesListened} time(s).`
|
||||||
|
: "You may listen to the audio as many times as you would like.";
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
key={partIndex}
|
key={partIndex}
|
||||||
src={exam.parts[partIndex].audio.source}
|
src={exam?.parts[partIndex]?.audio?.source ?? ''}
|
||||||
color="listening"
|
color="listening"
|
||||||
onEnd={() => setTimesListened((prev) => prev + 1)}
|
onEnd={() => setTimesListened((prev) => prev + 1)}
|
||||||
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes}
|
disabled={exam?.parts[partIndex]?.audio?.repeatableTimes != null &&
|
||||||
|
timesListened === exam.parts[partIndex]?.audio?.repeatableTimes}
|
||||||
disablePause
|
disablePause
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default function Writing({ exam, showSolutions = false, preview = false,
|
|||||||
const {
|
const {
|
||||||
userSolutions,
|
userSolutions,
|
||||||
exerciseIndex,
|
exerciseIndex,
|
||||||
|
hasExamEnded,
|
||||||
setBgColor,
|
setBgColor,
|
||||||
setUserSolutions,
|
setUserSolutions,
|
||||||
setHasExamEnded,
|
setHasExamEnded,
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export interface ListeningExam extends ExamBase {
|
|||||||
module: "listening";
|
module: "listening";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Dialog = { "name": string; "gender": string; "text": string; };
|
export type Message = { name: string; gender: string; text: string; voice?: string; };
|
||||||
export type Script = Dialog[] | string;
|
export type Script = Message[] | string;
|
||||||
|
|
||||||
export interface ListeningPart extends Section {
|
export interface ListeningPart extends Section {
|
||||||
audio?: {
|
audio?: {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import ReactDatePicker from "react-datepicker";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import countryCodes from "country-codes-list";
|
import countryCodes from "country-codes-list";
|
||||||
import { User, Type as UserType } from "@/interfaces/user";
|
import { User, Type as UserType } from "@/interfaces/user";
|
||||||
import { Type, UserImport } from "./IUserImport";
|
import { Type, UserImport } from "../../../interfaces/IUserImport";
|
||||||
import UserTable from "./UserTable";
|
import UserTable from "../../../components/UserTable";
|
||||||
import { EntityWithRoles } from "@/interfaces/entity";
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
import Select from "@/components/Low/Select";
|
import Select from "@/components/Low/Select";
|
||||||
|
|
||||||
@@ -64,11 +64,11 @@ const USER_TYPE_PERMISSIONS: {
|
|||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
permissions: PermissionType[];
|
permissions: PermissionType[];
|
||||||
entities: EntityWithRoles[]
|
entities?: EntityWithRoles[]
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchCreateUser({ user, entities, permissions, onFinish }: Props) {
|
export default function BatchCreateUser({ user, entities = [], permissions, onFinish }: Props) {
|
||||||
const [infos, setInfos] = useState<UserImport[]>([]);
|
const [infos, setInfos] = useState<UserImport[]>([]);
|
||||||
|
|
||||||
const [duplicatedUsers, setDuplicatedUsers] = useState<UserImport[]>([]);
|
const [duplicatedUsers, setDuplicatedUsers] = useState<UserImport[]>([]);
|
||||||
@@ -20,6 +20,7 @@ import Modal from "@/components/Modal";
|
|||||||
import {checkAccess} from "@/utils/permissions";
|
import {checkAccess} from "@/utils/permissions";
|
||||||
import useGroups from "@/hooks/useGroups";
|
import useGroups from "@/hooks/useGroups";
|
||||||
import Button from "@/components/Low/Button";
|
import Button from "@/components/Low/Button";
|
||||||
|
import { EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
|
||||||
const searchFields = [["module"], ["id"], ["createdBy"]];
|
const searchFields = [["module"], ["id"], ["createdBy"]];
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ const ExamOwnerSelector = ({options, exam, onSave}: {options: User[]; exam: Exam
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExamList({user}: {user: User}) {
|
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[];}) {
|
||||||
const [selectedExam, setSelectedExam] = useState<Exam>();
|
const [selectedExam, setSelectedExam] = useState<Exam>();
|
||||||
|
|
||||||
const {exams, reload} = useExams();
|
const {exams, reload} = useExams();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
queryParams.delete('module');
|
queryParams.delete('module');
|
||||||
|
|
||||||
const result = await axios.get(`${process.env.BACKEND_URL}/${endpoint}${queryParams.size > 0 ? `?${queryParams.toString()}` : ""}`, {
|
const result = await axios.get(`${process.env.BACKEND_URL}/${endpoint}${Array.from(queryParams.entries()).length > 0 ? `?${queryParams.toString()}` : ""}`, {
|
||||||
headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` },
|
headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}` },
|
||||||
});
|
});
|
||||||
res.status(200).json(result.data);
|
res.status(200).json(result.data);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Lists from "./(admin)/Lists";
|
|||||||
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
|
||||||
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
import { shouldRedirectHome } from "@/utils/navigation.disabled";
|
||||||
import ExamGenerator from "./(admin)/ExamGenerator";
|
import ExamGenerator from "./(admin)/ExamGenerator";
|
||||||
import BatchCreateUser from "./(admin)/BatchCreateUser";
|
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
|
||||||
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
import { checkAccess, getTypesOfUser } from "@/utils/permissions";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|||||||
Reference in New Issue
Block a user