Listening Convo Edit and a bunch of ts errors
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user