Files
npc-config-app/src/components/DialogueEditor.tsx
2025-08-11 21:32:22 +02:00

414 lines
15 KiB
TypeScript

import React, { useState } from 'react';
import { Plus, Trash2, MessageCircle } from 'lucide-react';
import type { DialogueConfiguration, DialoguePage, DialogueSpeaker } from '../types/npc';
interface DialogueEditorProps {
dialogue: DialogueConfiguration | null;
onChange: (dialogue: DialogueConfiguration) => void;
}
export const DialogueEditor: React.FC<DialogueEditorProps> = ({ dialogue, onChange }) => {
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [speakerEditMode, setSpeakerEditMode] = useState(false);
const initializeDialogue = () => {
const newDialogue: DialogueConfiguration = {
speakers: {
npc: {
name: { type: 'expression', expression: 'q.npc.name' },
face: 'q.npc.face(false);'
},
player: {
name: { type: 'expression', expression: 'q.player.username' },
face: 'q.player.face();'
}
},
pages: [{
id: 'greeting',
speaker: 'npc',
lines: ['Hello there!'],
input: 'q.dialogue.close();'
}]
};
onChange(newDialogue);
setSelectedPageId('greeting');
};
if (!dialogue) {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Dialogue Editor</h2>
<div className="text-center py-8">
<MessageCircle className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">No dialogue configured</p>
<button
type="button"
onClick={initializeDialogue}
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700"
>
Create Dialogue
</button>
</div>
</div>
);
}
const addPage = () => {
const newPageId = `page_${Date.now()}`;
const newPage: DialoguePage = {
id: newPageId,
speaker: 'npc',
lines: ['New dialogue line'],
input: 'q.dialogue.close();'
};
onChange({
...dialogue,
pages: [...dialogue.pages, newPage]
});
setSelectedPageId(newPageId);
};
const updatePage = (pageId: string, updatedPage: DialoguePage) => {
onChange({
...dialogue,
pages: dialogue.pages.map(page =>
page.id === pageId ? updatedPage : page
)
});
};
const deletePage = (pageId: string) => {
onChange({
...dialogue,
pages: dialogue.pages.filter(page => page.id !== pageId)
});
if (selectedPageId === pageId) {
setSelectedPageId(dialogue.pages.length > 1 ? dialogue.pages[0].id : null);
}
};
const addSpeaker = () => {
const speakerName = `speaker_${Object.keys(dialogue.speakers).length + 1}`;
onChange({
...dialogue,
speakers: {
...dialogue.speakers,
[speakerName]: {
name: { type: 'expression', expression: `'${speakerName}'` },
face: ''
}
}
});
};
const updateSpeaker = (speakerId: string, speaker: DialogueSpeaker) => {
onChange({
...dialogue,
speakers: {
...dialogue.speakers,
[speakerId]: speaker
}
});
};
const deleteSpeaker = (speakerId: string) => {
const newSpeakers = { ...dialogue.speakers };
delete newSpeakers[speakerId];
onChange({
...dialogue,
speakers: newSpeakers
});
};
const selectedPage = dialogue.pages.find(page => page.id === selectedPageId);
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Dialogue Editor</h2>
{/* Global Dialogue Settings */}
<div className="border rounded-lg p-4 space-y-3">
<h3 className="font-medium">Global Settings</h3>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700">Initialization Action</label>
<input
type="text"
value={dialogue.initializationAction || ''}
onChange={(e) => onChange({ ...dialogue, initializationAction: e.target.value || undefined })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.dialogue.close();"
/>
<p className="text-xs text-gray-500 mt-1">MoLang script executed when dialogue starts</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Escape Action</label>
<input
type="text"
value={dialogue.escapeAction || ''}
onChange={(e) => onChange({ ...dialogue, escapeAction: e.target.value || undefined })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.dialogue.close();"
/>
<p className="text-xs text-gray-500 mt-1">MoLang script executed when ESC is pressed</p>
</div>
</div>
</div>
{/* Speakers Management */}
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium">Speakers</h3>
<div className="space-x-2">
<button
type="button"
onClick={() => setSpeakerEditMode(!speakerEditMode)}
className="text-sm text-indigo-600 hover:text-indigo-800"
>
{speakerEditMode ? 'Done' : 'Edit Speakers'}
</button>
<button
type="button"
onClick={addSpeaker}
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Speaker
</button>
</div>
</div>
{speakerEditMode ? (
<div className="space-y-3">
{Object.entries(dialogue.speakers).map(([speakerId, speaker]) => (
<div key={speakerId} className="border rounded p-3 space-y-2">
<div className="flex items-center justify-between">
<strong className="text-sm">{speakerId}</strong>
{!['npc', 'player'].includes(speakerId) && (
<button
type="button"
onClick={() => deleteSpeaker(speakerId)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-700">Name Expression</label>
<input
type="text"
value={speaker.name.expression || ''}
onChange={(e) => updateSpeaker(speakerId, {
...speaker,
name: { ...speaker.name, expression: e.target.value }
})}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.npc.name"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Face Expression</label>
<input
type="text"
value={speaker.face || ''}
onChange={(e) => updateSpeaker(speakerId, {
...speaker,
face: e.target.value
})}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="q.npc.face(false);"
/>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-wrap gap-2">
{Object.keys(dialogue.speakers).map(speakerId => (
<span key={speakerId} className="px-2 py-1 bg-gray-100 rounded-md text-sm">
{speakerId}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Pages List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">Pages</h3>
<button
type="button"
onClick={addPage}
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Page
</button>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto">
{dialogue.pages.map(page => (
<div
key={page.id}
className={`p-2 rounded cursor-pointer border ${
selectedPageId === page.id
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
onClick={() => setSelectedPageId(page.id)}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">{page.id}</div>
<div className="text-xs text-gray-500">Speaker: {page.speaker}</div>
<div className="text-xs text-gray-600 truncate">
{Array.isArray(page.lines)
? (typeof page.lines[0] === 'string' ? page.lines[0] : page.lines[0]?.text || 'Expression')
: (typeof page.lines === 'string' ? page.lines : page.lines?.text || 'Expression')}
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
deletePage(page.id);
}}
className="text-red-600 hover:text-red-800 opacity-0 group-hover:opacity-100"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Page Editor */}
<div className="lg:col-span-2">
{selectedPage ? (
<PageEditor
page={selectedPage}
speakers={dialogue.speakers}
onChange={(updatedPage) => updatePage(selectedPage.id, updatedPage)}
/>
) : (
<div className="text-center py-8 text-gray-500">
Select a page to edit
</div>
)}
</div>
</div>
</div>
);
};
interface PageEditorProps {
page: DialoguePage;
speakers: Record<string, DialogueSpeaker>;
onChange: (page: DialoguePage) => void;
}
const PageEditor: React.FC<PageEditorProps> = ({ page, speakers, onChange }) => {
const updatePage = (field: keyof DialoguePage, value: any) => {
onChange({ ...page, [field]: value });
};
const updateLine = (index: number, value: string) => {
const newLines = [...page.lines];
newLines[index] = value;
updatePage('lines', newLines);
};
const addLine = () => {
updatePage('lines', [...page.lines, '']);
};
const removeLine = (index: number) => {
updatePage('lines', page.lines.filter((_, i) => i !== index));
};
return (
<div className="space-y-4 border rounded-lg p-4">
<h3 className="font-medium text-lg">Edit Page: {page.id}</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Page ID</label>
<input
type="text"
value={page.id}
onChange={(e) => updatePage('id', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Speaker</label>
<select
value={page.speaker}
onChange={(e) => updatePage('speaker', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
{Object.keys(speakers).map(speakerId => (
<option key={speakerId} value={speakerId}>{speakerId}</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">Lines</label>
<button
type="button"
onClick={addLine}
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200"
>
<Plus className="h-3 w-3 mr-1" />
Add Line
</button>
</div>
<div className="space-y-2">
{page.lines.map((line, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type="text"
value={typeof line === 'string' ? line : line.expression || ''}
onChange={(e) => updateLine(index, e.target.value)}
className="flex-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
placeholder="Dialogue text or MoLang expression"
/>
<button
type="button"
onClick={() => removeLine(index)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Input/Action</label>
<textarea
value={typeof page.input === 'string' ? page.input : JSON.stringify(page.input, null, 2)}
onChange={(e) => updatePage('input', e.target.value)}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm font-mono"
placeholder="q.dialogue.close(); or JSON for options"
/>
<p className="text-xs text-gray-500 mt-1">
MoLang script or JSON for dialogue options
</p>
</div>
</div>
);
};