nodes-ui-flow / src /components /NodeShell.jsx
markitzeroo's picture
Initial deploy: Dockerized FastAPI + React frontend
cfaaa6c verified
import { useEffect, useRef, useState } from 'react';
import { Handle, Position } from '@xyflow/react';
import { useWorkflow } from '../context/WorkflowContext.jsx';
function getStatusLabel(status) {
switch (status) {
case 'running':
return 'Running';
case 'waiting':
return 'Waiting';
case 'skipped':
return 'Skipped';
case 'success':
return 'Ready';
case 'error':
return 'Error';
default:
return '';
}
}
const HANDLE_COLORS_BY_ID = {
answer: '#38bdf8',
question: '#a78bfa',
turn: '#f59e0b',
dialog: '#22c55e',
'dialog-in': '#22c55e',
};
const HANDLE_COLORS_BY_DATA_TYPE = {
string: '#38bdf8',
object: '#f59e0b',
'*': '#94a3b8',
};
function getHandleColor(handle) {
return HANDLE_COLORS_BY_ID[handle?.id] || HANDLE_COLORS_BY_DATA_TYPE[handle?.dataType] || '#94a3b8';
}
export default function NodeShell({
nodeId,
title,
accent,
selected,
status,
inputs = [],
outputs = [],
children,
footer,
className = '',
bodyClassName = '',
renderInputAddon,
}) {
const { patchNodeData } = useWorkflow();
const rows = Math.max(inputs.length, outputs.length);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState(title || '');
const titleInputRef = useRef(null);
useEffect(() => {
if (!isEditingTitle) {
setDraftTitle(title || '');
}
}, [isEditingTitle, title]);
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [isEditingTitle]);
const finishTitleEditing = () => {
const nextTitle = draftTitle.trim() || title || 'Untitled';
if (nodeId && nextTitle !== title) {
patchNodeData(nodeId, { title: nextTitle });
}
setDraftTitle(nextTitle);
setIsEditingTitle(false);
};
return (
<div
className={[
'node-shell',
selected ? 'is-selected' : '',
status ? `status-${status}` : '',
className,
]
.filter(Boolean)
.join(' ')}
style={{ '--node-accent': accent }}
>
<div className="node-shell__header drag-handle">
{isEditingTitle ? (
<input
ref={titleInputRef}
className="nodrag node-shell__title-input"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onBlur={finishTitleEditing}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
finishTitleEditing();
}
if (event.key === 'Escape') {
event.preventDefault();
setDraftTitle(title || '');
setIsEditingTitle(false);
}
}}
/>
) : (
<button
type="button"
className="nodrag node-shell__title node-shell__title-button"
title="Нажмите, чтобы переименовать"
onClick={() => {
if (nodeId) {
setIsEditingTitle(true);
}
}}
>
{title}
</button>
)}
{status && status !== 'idle' ? (
<div className={`node-shell__status-pill status-${status || 'idle'}`}>
{getStatusLabel(status)}
</div>
) : null}
</div>
{rows > 0 ? (
<div className="node-shell__ports">
{Array.from({ length: rows }).map((_, index) => {
const input = inputs[index];
const output = outputs[index];
return (
<div className="node-shell__port-row" key={`${input?.id || 'empty'}-${output?.id || 'empty'}`}>
<div className="node-shell__port node-shell__port--input">
{input ? (
<>
<Handle
type="target"
position={Position.Left}
id={input.id}
className="node-shell__handle"
style={{ top: '50%', '--handle-color': getHandleColor(input) }}
/>
<div className="node-shell__port-content node-shell__port-content--input">
<span>{input.label}</span>
{renderInputAddon ? renderInputAddon(input, index) : null}
</div>
</>
) : (
<span className="node-shell__port-placeholder" />
)}
</div>
<div className="node-shell__port node-shell__port--output">
{output ? (
<>
<Handle
type="source"
position={Position.Right}
id={output.id}
className="node-shell__handle"
style={{ top: '50%', '--handle-color': getHandleColor(output) }}
/>
<div className="node-shell__port-content node-shell__port-content--output">
<span>{output.label}</span>
</div>
</>
) : (
<span className="node-shell__port-placeholder" />
)}
</div>
</div>
);
})}
</div>
) : null}
<div className={`node-shell__body ${rows === 0 ? 'node-shell__body--flush' : ''} ${bodyClassName}`.trim()}>
{children}
</div>
{footer ? <div className="node-shell__footer">{footer}</div> : null}
</div>
);
}