Working on dialog editor, on dialogs, on phone

This commit is contained in:
Vladislav Khorev committed 2026-06-05 21:17:51 +03:00
1 parent 0e27521bea
commit 8f7797789d
63 files changed
+9502 -1459

No files matched your search

@@ -0,0 +1,313 @@
.panel {
width: 300px;
min-width: 300px;
background: #181825;
border-left: 1px solid #313244;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.emptyMsg {
padding: 24px 16px;
color: #6c7086;
font-size: 13px;
text-align: center;
}
.inspector {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.inspectorHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.nodeTypeBadge {
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
color: #fff;
}
.nodeIdLabel {
flex: 1;
font-size: 11px;
font-family: monospace;
color: #6c7086;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deleteNodeBtn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 2px;
color: #6c7086;
transition: color 0.15s;
}
.deleteNodeBtn:hover {
color: #f38ba8;
}
.label {
display: block;
font-size: 10px;
font-weight: 600;
color: #6c7086;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 3px;
}
.input {
width: 100%;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 12px;
padding: 5px 8px;
box-sizing: border-box;
font-family: inherit;
transition: border-color 0.15s;
}
.input:focus {
outline: none;
border-color: #89b4fa;
}
.textarea {
width: 100%;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 12px;
padding: 5px 8px;
box-sizing: border-box;
resize: vertical;
font-family: inherit;
line-height: 1.5;
transition: border-color 0.15s;
}
.textarea:focus {
outline: none;
border-color: #89b4fa;
}
.select {
width: 100%;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 12px;
padding: 5px 8px;
box-sizing: border-box;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: #89b4fa;
}
.selectSmall {
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 11px;
padding: 3px 6px;
cursor: pointer;
flex-shrink: 0;
}
.inputSmall {
flex: 1;
min-width: 0;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 4px;
color: #cdd6f4;
font-size: 11px;
padding: 3px 6px;
font-family: monospace;
}
.inputSmall:focus {
outline: none;
border-color: #89b4fa;
}
.advancedToggle {
background: none;
border: none;
color: #6c7086;
font-size: 11px;
cursor: pointer;
padding: 4px 0;
text-align: left;
}
.advancedToggle:hover {
color: #cdd6f4;
}
.advancedSection {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
border: 1px solid #313244;
}
.issueList {
display: flex;
flex-direction: column;
gap: 3px;
}
.issueError {
background: #2a0e14;
color: #f38ba8;
font-size: 11px;
padding: 5px 8px;
border-radius: 4px;
border-left: 3px solid #f38ba8;
}
.issueWarning {
background: #2a1f00;
color: #f9e2af;
font-size: 11px;
padding: 5px 8px;
border-radius: 4px;
border-left: 3px solid #f9e2af;
}
/* Choice editor */
.choiceEditorRow {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: #1e1e2e;
border-radius: 4px;
border: 1px solid #313244;
margin-top: 4px;
}
.choiceEditorHeader {
display: flex;
align-items: center;
gap: 6px;
}
.choiceIdLabel {
flex: 1;
font-size: 10px;
color: #6c7086;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.addBtn {
background: #313244;
color: #cdd6f4;
border: none;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
}
.addBtn:hover {
background: #45475a;
}
.removeBtn {
background: none;
border: none;
color: #6c7086;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.removeBtn:hover {
color: #f38ba8;
}
/* Clause / effect rows */
.clauseRow {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
}
.eqLabel {
color: #cba6f7;
font-weight: 700;
font-size: 13px;
}
.emptyHint {
color: #6c7086;
font-size: 11px;
font-style: italic;
padding: 4px 0;
}
/* Dialogue meta panel */
.dialogueMeta {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.metaHeader {
font-size: 12px;
font-weight: 700;
color: #cdd6f4;
margin-bottom: 4px;
}
.statRow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.statPill {
background: #313244;
color: #a6adc8;
font-size: 10px;
padding: 2px 7px;
border-radius: 10px;
}
@@ -0,0 +1,102 @@
import { useDialogueStore } from '../../store/dialogueStore';
import { LineInspector } from './inspectors/LineInspector';
import { ChoiceInspector } from './inspectors/ChoiceInspector';
import { ConditionInspector } from './inspectors/ConditionInspector';
import { SetFlagInspector } from './inspectors/SetFlagInspector';
import { CutsceneStartInspector, EndInspector } from './inspectors/OtherInspectors';
import { DialogueNode } from '../../types/dialogue';
import styles from './RightPanel.module.css';
// Helper to update start node ID
function useUpdateStart() {
const store = useDialogueStore;
return (dialogueId: string, start: string) => {
store.setState(state => {
const d = state.file?.dialogues.find(x => x.id === dialogueId);
if (d) d.start = start;
});
};
}
export function RightPanel() {
const { file, selectedDialogueId, selectedNodeId } = useDialogueStore();
const updateStart = useUpdateStart();
const dialogue = file?.dialogues.find(d => d.id === selectedDialogueId);
if (!dialogue) {
return (
<div className={styles.panel}>
<div className={styles.emptyMsg}>No dialogue selected</div>
</div>
);
}
if (!selectedNodeId) {
return (
<div className={styles.panel}>
<div className={styles.dialogueMeta}>
<div className={styles.metaHeader}>Dialogue Properties</div>
<div>
<label className={styles.label}>ID</label>
<input className={styles.input} value={dialogue.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Start node</label>
<input
className={styles.input}
value={dialogue.start}
onChange={e => {
if (selectedDialogueId) updateStart(selectedDialogueId, e.target.value);
}}
list="startNodeList"
/>
<datalist id="startNodeList">
{dialogue.nodes.map(n => <option key={n.id} value={n.id} />)}
</datalist>
</div>
<div className={styles.emptyHint} style={{ marginTop: 16 }}>
Click a node in the graph to inspect it.
</div>
<div style={{ marginTop: 12 }}>
<div className={styles.metaHeader}>Node Count</div>
<div className={styles.statRow}>
{(['Line', 'Choice', 'Condition', 'SetFlag', 'CutsceneStart', 'End'] as DialogueNode['type'][]).map(t => {
const count = dialogue.nodes.filter(n => n.type === t).length;
return count > 0 ? (
<span key={t} className={styles.statPill}>{t}: {count}</span>
) : null;
})}
</div>
</div>
</div>
</div>
);
}
const node = dialogue.nodes.find(n => n.id === selectedNodeId);
if (!node) return null;
return (
<div className={styles.panel}>
{node.type === 'Line' && (
<LineInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'Choice' && (
<ChoiceInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'Condition' && (
<ConditionInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'SetFlag' && (
<SetFlagInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'CutsceneStart' && (
<CutsceneStartInspector node={node} dialogueId={dialogue.id} />
)}
{node.type === 'End' && (
<EndInspector node={node} dialogueId={dialogue.id} />
)}
</div>
);
}
@@ -0,0 +1,110 @@
import { ChoiceNode, ChoiceOption } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { SpeakerField } from '../shared/SpeakerField';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
interface Props {
node: ChoiceNode;
dialogueId: string;
}
export function ChoiceInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function updateChoice(idx: number, patch: Partial<ChoiceOption>) {
const choices = node.choices.map((c, i) => i === idx ? { ...c, ...patch } : c);
updateNode(dialogueId, node.id, { choices });
}
function addChoice() {
const newId = `choice_${Date.now()}`;
const choices: ChoiceOption[] = [...node.choices, { id: newId, kind: 'Optional', text: '', next: '' }];
updateNode(dialogueId, node.id, { choices });
}
function removeChoice(idx: number) {
const choices = node.choices.filter((_, i) => i !== idx);
updateNode(dialogueId, node.id, { choices });
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#df8e1d' }}>Choice</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<SpeakerField
speaker={node.speaker}
portrait={node.portrait}
onSpeakerChange={(s, p) => updateNode(dialogueId, node.id, { speaker: s, portrait: p })}
/>
<div style={{ marginTop: 12 }}>
<div className={styles.sectionHeader}>
<span className={styles.label}>Choices</span>
<button className={styles.addBtn} onClick={addChoice}>+ Add</button>
</div>
{node.choices.map((choice, idx) => (
<div key={choice.id} className={styles.choiceEditorRow}>
<div className={styles.choiceEditorHeader}>
<select
className={styles.selectSmall}
value={choice.kind}
onChange={e => updateChoice(idx, { kind: e.target.value as 'Main' | 'Optional' })}
>
<option value="Main">Main</option>
<option value="Optional">Optional</option>
</select>
<span className={styles.choiceIdLabel}>#{idx + 1}</span>
<button className={styles.removeBtn} onClick={() => removeChoice(idx)}>×</button>
</div>
<input
className={styles.input}
value={choice.text}
placeholder="Choice text..."
onChange={e => updateChoice(idx, { text: e.target.value })}
/>
<input
className={styles.input}
value={choice.next}
placeholder="Next node ID..."
onChange={e => updateChoice(idx, { next: e.target.value })}
list={`nodelist-choice-${choice.id}`}
/>
<datalist id={`nodelist-choice-${choice.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
))}
{node.choices.length === 0 && (
<div className={styles.emptyHint}>No choices yet. Click "+ Add".</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { ConditionNode, ConditionClause, ConditionOp } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
const OPS: ConditionOp[] = ['Equals', 'NotEquals', 'GreaterThan', 'LessThan'];
interface Props {
node: ConditionNode;
dialogueId: string;
}
export function ConditionInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function updateClause(idx: number, patch: Partial<ConditionClause>) {
const conditions = node.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c);
updateNode(dialogueId, node.id, { conditions });
}
function addClause() {
const conditions: ConditionClause[] = [...node.conditions, { flag: '', op: 'Equals', value: 1 }];
updateNode(dialogueId, node.id, { conditions });
}
function removeClause(idx: number) {
const conditions = node.conditions.filter((_, i) => i !== idx);
updateNode(dialogueId, node.id, { conditions });
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#8839ef' }}>Condition</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<div className={styles.sectionHeader}>
<span className={styles.label}>Conditions (ALL must pass)</span>
<button className={styles.addBtn} onClick={addClause}>+ Add</button>
</div>
{node.conditions.map((c, idx) => (
<div key={idx} className={styles.clauseRow}>
<input
className={styles.inputSmall}
value={c.flag}
placeholder="flag"
onChange={e => updateClause(idx, { flag: e.target.value })}
/>
<select
className={styles.selectSmall}
value={c.op}
onChange={e => updateClause(idx, { op: e.target.value as ConditionOp })}
>
{OPS.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input
className={styles.inputSmall}
value={String(c.value)}
placeholder="value"
onChange={e => updateClause(idx, { value: isNaN(Number(e.target.value)) ? e.target.value : Number(e.target.value) })}
/>
<button className={styles.removeBtn} onClick={() => removeClause(idx)}>×</button>
</div>
))}
{node.conditions.length === 0 && <div className={styles.emptyHint}>No conditions. Always true.</div>}
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>True → next node</label>
<input
className={styles.input}
value={node.trueNext}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { trueNext: e.target.value })}
list={`nodelist-true-${node.id}`}
/>
<datalist id={`nodelist-true-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>False → next node</label>
<input
className={styles.input}
value={node.falseNext}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { falseNext: e.target.value })}
list={`nodelist-false-${node.id}`}
/>
<datalist id={`nodelist-false-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import { useState } from 'react';
import { LineNode } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { SpeakerField } from '../shared/SpeakerField';
import { AutoSaveTextArea } from '../shared/TextArea';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
interface Props {
node: LineNode;
dialogueId: string;
}
export function LineInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const [showAdvanced, setShowAdvanced] = useState(false);
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function update(patch: Partial<LineNode>) {
updateNode(dialogueId, node.id, patch);
}
function field(label: string, key: keyof LineNode, placeholder?: string) {
const val = (node[key] as string) ?? '';
return (
<div>
<label className={styles.label}>{label}</label>
<input
className={styles.input}
value={val}
placeholder={placeholder}
onChange={e => update({ [key]: e.target.value } as Partial<LineNode>)}
list={key === 'next' ? `nodelist-${node.id}` : undefined}
/>
{key === 'next' && (
<datalist id={`nodelist-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
)}
</div>
);
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#1e66f5' }}>Line</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
{field('Node ID', 'id')}
<SpeakerField
speaker={node.speaker}
portrait={node.portrait}
onSpeakerChange={(s, p) => update({ speaker: s, portrait: p })}
/>
<AutoSaveTextArea
label="Text"
value={node.text}
placeholder="Dialogue line text..."
onSave={v => update({ text: v })}
/>
{field('Next node', 'next', 'node_id')}
{dialogue?.mobileMode && (
<div>
<label className={styles.label}>Chat Bubble</label>
<select
className={styles.select}
value={node.chatBubble ?? ''}
onChange={e => update({ chatBubble: e.target.value as 'in' | 'out' | undefined || undefined })}
>
<option value="">(auto)</option>
<option value="in">in</option>
<option value="out">out</option>
</select>
</div>
)}
<button
className={styles.advancedToggle}
onClick={() => setShowAdvanced(v => !v)}
>
{showAdvanced ? '▼' : '▶'} Advanced triggers
</button>
{showAdvanced && (
<div className={styles.advancedSection}>
{field('questUnlock', 'questUnlock', 'quest_id')}
{field('objectiveComplete', 'objectiveComplete', 'group.objective')}
{field('objectiveVisible', 'objectiveVisible', 'group.objective')}
{field('questFail', 'questFail', 'quest_id')}
{field('questComplete', 'questComplete', 'quest_id')}
{field('luaCallback', 'luaCallback', 'on_event_name')}
</div>
)}
</div>
);
}
@@ -0,0 +1,76 @@
import { CutsceneStartNode, EndNode } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import styles from '../RightPanel.module.css';
interface CutsceneProps {
node: CutsceneStartNode;
dialogueId: string;
}
export function CutsceneStartInspector({ node, dialogueId }: CutsceneProps) {
const { updateNode, deleteNode, file } = useDialogueStore();
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#4a4a5a' }}>CutsceneStart</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Cutscene ID</label>
<input
className={styles.input}
value={node.cutsceneId}
placeholder="cutscene_id"
onChange={e => updateNode(dialogueId, node.id, { cutsceneId: e.target.value })}
/>
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Next node</label>
<input
className={styles.input}
value={node.next}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { next: e.target.value })}
list={`nodelist-cs-${node.id}`}
/>
<datalist id={`nodelist-cs-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
</div>
);
}
interface EndProps {
node: EndNode;
dialogueId: string;
}
export function EndInspector({ node, dialogueId }: EndProps) {
const { deleteNode } = useDialogueStore();
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#f38ba8', color: '#1e1e2e' }}>End</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div className={styles.emptyHint} style={{ marginTop: 12 }}>
This is a terminal node. Dialogue ends here.
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import { SetFlagNode, FlagEffect } from '../../../types/dialogue';
import { useDialogueStore } from '../../../store/dialogueStore';
import { useValidation } from '../../../hooks/useValidation';
import styles from '../RightPanel.module.css';
interface Props {
node: SetFlagNode;
dialogueId: string;
}
export function SetFlagInspector({ node, dialogueId }: Props) {
const { updateNode, deleteNode, file } = useDialogueStore();
const { issuesByNodeId } = useValidation();
const issues = issuesByNodeId[node.id] ?? [];
const dialogue = file?.dialogues.find(d => d.id === dialogueId);
const nodeIds = dialogue?.nodes.map(n => n.id) ?? [];
function updateEffect(idx: number, patch: Partial<FlagEffect>) {
const effects = node.effects.map((e, i) => i === idx ? { ...e, ...patch } : e);
updateNode(dialogueId, node.id, { effects });
}
function addEffect() {
const effects: FlagEffect[] = [...node.effects, { flag: '', value: 1 }];
updateNode(dialogueId, node.id, { effects });
}
function removeEffect(idx: number) {
const effects = node.effects.filter((_, i) => i !== idx);
updateNode(dialogueId, node.id, { effects });
}
return (
<div className={styles.inspector}>
<div className={styles.inspectorHeader}>
<span className={styles.nodeTypeBadge} style={{ background: '#179299' }}>SetFlag</span>
<span className={styles.nodeIdLabel}>{node.id}</span>
<button className={styles.deleteNodeBtn} onClick={() => deleteNode(dialogueId, node.id)} title="Delete node">🗑</button>
</div>
{issues.length > 0 && (
<div className={styles.issueList}>
{issues.map((issue, i) => (
<div key={i} className={issue.severity === 'error' ? styles.issueError : styles.issueWarning}>
{issue.severity === 'error' ? '⚠' : '!'} {issue.message}
</div>
))}
</div>
)}
<div>
<label className={styles.label}>Node ID</label>
<input className={styles.input} value={node.id} readOnly />
</div>
<div style={{ marginTop: 8 }}>
<div className={styles.sectionHeader}>
<span className={styles.label}>Flag Effects</span>
<button className={styles.addBtn} onClick={addEffect}>+ Add</button>
</div>
{node.effects.map((e, idx) => (
<div key={idx} className={styles.clauseRow}>
<input
className={styles.inputSmall}
value={e.flag}
placeholder="flag_name"
onChange={ev => updateEffect(idx, { flag: ev.target.value })}
/>
<span className={styles.eqLabel}>=</span>
<input
className={styles.inputSmall}
value={String(e.value)}
placeholder="value"
onChange={ev => updateEffect(idx, { value: isNaN(Number(ev.target.value)) ? ev.target.value : Number(ev.target.value) })}
/>
<button className={styles.removeBtn} onClick={() => removeEffect(idx)}>×</button>
</div>
))}
{node.effects.length === 0 && <div className={styles.emptyHint}>No effects defined.</div>}
</div>
<div style={{ marginTop: 8 }}>
<label className={styles.label}>Next node</label>
<input
className={styles.input}
value={node.next}
placeholder="node_id"
onChange={e => updateNode(dialogueId, node.id, { next: e.target.value })}
list={`nodelist-sf-${node.id}`}
/>
<datalist id={`nodelist-sf-${node.id}`}>
{nodeIds.map(id => <option key={id} value={id} />)}
</datalist>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { useState } from 'react';
import { CHARACTER_PRESETS, CUSTOM_CHARACTER_LABEL } from '../../../constants/characters';
import styles from '../RightPanel.module.css';
interface Props {
speaker: string;
portrait: string;
onSpeakerChange: (speaker: string, portrait: string) => void;
}
export function SpeakerField({ speaker, portrait, onSpeakerChange }: Props) {
const isPreset = CHARACTER_PRESETS.some(p => p.label === speaker);
const [isCustom, setIsCustom] = useState(!isPreset);
function handleSelect(e: React.ChangeEvent<HTMLSelectElement>) {
const val = e.target.value;
if (val === CUSTOM_CHARACTER_LABEL) {
setIsCustom(true);
onSpeakerChange(speaker, portrait);
} else {
setIsCustom(false);
const preset = CHARACTER_PRESETS.find(p => p.label === val);
onSpeakerChange(val, preset?.portrait ?? '');
}
}
return (
<div>
<label className={styles.label}>Speaker</label>
<select
className={styles.select}
value={isCustom ? CUSTOM_CHARACTER_LABEL : speaker}
onChange={handleSelect}
>
{CHARACTER_PRESETS.map(p => (
<option key={p.label} value={p.label}>{p.label}</option>
))}
<option value={CUSTOM_CHARACTER_LABEL}>{CUSTOM_CHARACTER_LABEL}</option>
</select>
{isCustom && (
<input
className={styles.input}
style={{ marginTop: 4 }}
value={speaker}
placeholder="Speaker name"
onChange={e => onSpeakerChange(e.target.value, portrait)}
/>
)}
<label className={styles.label} style={{ marginTop: 8 }}>Portrait path</label>
<input
className={styles.input}
value={portrait}
placeholder="resources/dialogue/portrait_..."
onChange={e => onSpeakerChange(speaker, e.target.value)}
/>
</div>
);
}
@@ -0,0 +1,26 @@
import { useAutoSave } from '../../../hooks/useAutoSave';
import styles from '../RightPanel.module.css';
interface Props {
value: string;
label: string;
placeholder?: string;
onSave: (value: string) => void;
rows?: number;
}
export function AutoSaveTextArea({ value, label, placeholder, onSave, rows = 4 }: Props) {
const [local, handleChange] = useAutoSave(value, onSave);
return (
<div>
<label className={styles.label}>{label}</label>
<textarea
className={styles.textarea}
value={local}
placeholder={placeholder}
rows={rows}
onChange={e => handleChange(e.target.value)}
/>
</div>
);
}