348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
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 speakerCount = section === 1 ? 2 : 4;
|
|
|
|
if (local === undefined) {
|
|
if (isConversation) {
|
|
setLocal([]);
|
|
} else {
|
|
setLocal('');
|
|
}
|
|
}
|
|
|
|
const [selectedSpeaker, setSelectedSpeaker] = useState<string>('');
|
|
const [newMessage, setNewMessage] = useState('');
|
|
|
|
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
|
|
if (local === undefined) {
|
|
return Array.from({ length: speakerCount }, (_, index) => ({
|
|
id: index,
|
|
name: '',
|
|
gender: 'male',
|
|
color: colorOptions[index],
|
|
position: index % 2 === 0 ? 'left' : 'right'
|
|
}));
|
|
}
|
|
|
|
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 mb-8">
|
|
<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;
|