refactor: LiteGraph-style node layout with NodeShell wrapper
- NodeShell component wraps all nodes with handle rendering + px-8 content padding
- Handles positioned at node edges with labels outside (no overlap with content)
- Labels: 9px mono font, full opacity, color-matched to socket type
- Removed old TypedHandle + NodeHandles (replaced by NodeShell internals)
- All 18 node components updated to use NodeShell
- Format node dynamically generates handles from template {0}, {1}, etc.
This commit is contained in:
parent
707562faaa
commit
2ffd5e2a84
21 changed files with 165 additions and 213 deletions
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { CheckCircleIcon } from "lucide-react";
|
import { CheckCircleIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function BooleanNode({ data }: NodeProps) {
|
export function BooleanNode({ data }: NodeProps) {
|
||||||
const d = data as { value?: boolean; description?: string };
|
const d = data as { value?: boolean; description?: string };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28">
|
<NodeShell nodeType="boolean" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28">
|
||||||
<NodeHandles nodeType="boolean" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
|
||||||
<CheckCircleIcon className="size-4 text-emerald-400 shrink-0" />
|
<CheckCircleIcon className="size-4 text-emerald-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
|
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
|
||||||
|
|
@ -19,6 +18,6 @@ export function BooleanNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-emerald-300/80 line-clamp-2">
|
<div className="px-3 py-2 text-xs text-emerald-300/80 line-clamp-2">
|
||||||
{d.description || "Boolean flag"}
|
{d.description || "Boolean flag"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,19 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { GitBranchIcon } from "lucide-react";
|
import { GitBranchIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function BranchNode(_props: NodeProps) {
|
export function BranchNode(_props: NodeProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36">
|
<NodeShell nodeType="branch" className="bg-rose-950/80 border border-rose-500/50 rounded-lg shadow-md min-w-36">
|
||||||
<NodeHandles nodeType="branch" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-rose-500/30">
|
||||||
<GitBranchIcon className="size-4 text-rose-400 shrink-0" />
|
<GitBranchIcon className="size-4 text-rose-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-rose-200 flex-1">
|
<span className="text-sm font-medium text-rose-200 flex-1">Branch</span>
|
||||||
Branch
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 flex justify-between text-xs">
|
<div className="px-3 py-2 flex justify-between text-xs">
|
||||||
<span className="text-emerald-400">true</span>
|
<span className="text-emerald-400">true</span>
|
||||||
<span className="text-red-400">false</span>
|
<span className="text-red-400">false</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,14 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { PencilIcon, WaypointsIcon } from "lucide-react";
|
import { PencilIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function ChoiceNode({ data }: NodeProps) {
|
export function ChoiceNode({ data }: NodeProps) {
|
||||||
const d = data as { question?: string; options?: Array<{ id: string; label: string }> };
|
const d = data as { question?: string; options?: Array<{ id: string; label: string }> };
|
||||||
const options = d.options ?? [];
|
const options = d.options ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56">
|
<NodeShell nodeType="choice" className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56">
|
||||||
<NodeHandles nodeType="choice" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-amber-500/30">
|
||||||
<WaypointsIcon className="size-4 text-amber-400 shrink-0" />
|
<WaypointsIcon className="size-4 text-amber-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-amber-200 flex-1 truncate">
|
<span className="text-sm font-medium text-amber-200 flex-1 truncate">
|
||||||
|
|
@ -33,6 +32,6 @@ export function ChoiceNode({ data }: NodeProps) {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,19 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { EqualIcon } from "lucide-react";
|
import { EqualIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function CompareNode({ data }: NodeProps) {
|
export function CompareNode({ data }: NodeProps) {
|
||||||
const d = data as { operator?: string };
|
const d = data as { operator?: string };
|
||||||
const op = d.operator ?? ">";
|
const op = d.operator ?? ">";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="compare" className="bg-slate-950/80 border border-slate-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="compare" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-500/30">
|
||||||
<EqualIcon className="size-4 text-slate-400 shrink-0" />
|
<EqualIcon className="size-4 text-slate-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-slate-200 flex-1">
|
<span className="text-sm font-medium text-slate-200 flex-1">Compare</span>
|
||||||
Compare
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 text-center text-lg font-mono text-slate-300">
|
<div className="px-3 py-2 text-center text-lg font-mono text-slate-300">{op}</div>
|
||||||
{op}
|
</NodeShell>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function DateNode({ data }: NodeProps) {
|
export function DateNode({ data }: NodeProps) {
|
||||||
const d = data as { value?: string; description?: string };
|
const d = data as { value?: string; description?: string };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="date" className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="date" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-cyan-500/30">
|
||||||
<CalendarIcon className="size-4 text-cyan-400 shrink-0" />
|
<CalendarIcon className="size-4 text-cyan-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-cyan-200 flex-1 truncate">
|
<span className="text-sm font-medium text-cyan-200 flex-1 truncate">
|
||||||
|
|
@ -19,6 +18,6 @@ export function DateNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-cyan-300/80 line-clamp-2">
|
<div className="px-3 py-2 text-xs text-cyan-300/80 line-clamp-2">
|
||||||
{d.description || "Date value"}
|
{d.description || "Date value"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { FlagIcon } from "lucide-react";
|
import { FlagIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function FlagNode({ data }: NodeProps) {
|
export function FlagNode({ data }: NodeProps) {
|
||||||
const d = data as { flagName?: string };
|
const d = data as { flagName?: string };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40">
|
<NodeShell nodeType="flag" className="bg-orange-950/80 border border-orange-500/50 rounded-lg shadow-md min-w-40">
|
||||||
<NodeHandles nodeType="flag" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-500/30">
|
||||||
<FlagIcon className="size-4 text-orange-400 shrink-0" />
|
<FlagIcon className="size-4 text-orange-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-orange-200 flex-1 truncate">
|
<span className="text-sm font-medium text-orange-200 flex-1 truncate">
|
||||||
|
|
@ -19,6 +18,6 @@ export function FlagNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-orange-300/80 truncate">
|
<div className="px-3 py-2 text-xs text-orange-300/80 truncate">
|
||||||
{d.flagName || "Unnamed flag"}
|
{d.flagName || "Unnamed flag"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { AlignLeftIcon } from "lucide-react";
|
import { AlignLeftIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
import { NODE_SOCKETS, type SocketDef } from "./types";
|
import { NODE_SOCKETS, type SocketDef } from "./types";
|
||||||
|
|
||||||
function parseTemplateArgs(template: string): SocketDef[] {
|
function parseTemplateArgs(template: string): SocketDef[] {
|
||||||
|
|
@ -25,17 +25,12 @@ export function FormatNode({ data }: NodeProps) {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40">
|
<NodeShell nodeType="format" dynamicInputs={dynamicInputs} className="bg-sky-950/80 border border-sky-500/50 rounded-lg shadow-md min-w-40">
|
||||||
<NodeHandles nodeType="format" dynamicInputs={dynamicInputs} />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-sky-500/30">
|
||||||
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
|
<AlignLeftIcon className="size-4 text-sky-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-sky-200 flex-1">
|
<span className="text-sm font-medium text-sky-200 flex-1">Format</span>
|
||||||
Format
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 text-xs text-sky-300/80 font-mono truncate">
|
<div className="px-3 py-2 text-xs text-sky-300/80 font-mono truncate">{template}</div>
|
||||||
{template}
|
</NodeShell>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { DatabaseIcon } from "lucide-react";
|
import { DatabaseIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
const GAME_DATA_FIELDS = [
|
const GAME_DATA_FIELDS = [
|
||||||
{ value: "structure_health", label: "Structure Health" },
|
{ value: "structure_health", label: "Structure Health" },
|
||||||
|
|
@ -20,8 +20,7 @@ export function GameDataNode({ data }: NodeProps) {
|
||||||
const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field";
|
const fieldLabel = GAME_DATA_FIELDS.find((f) => f.value === d.field)?.label ?? "Select field";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44">
|
<NodeShell nodeType="gameData" className="bg-indigo-950/80 border border-indigo-500/50 rounded-lg shadow-md min-w-44">
|
||||||
<NodeHandles nodeType="gameData" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-indigo-500/30">
|
||||||
<DatabaseIcon className="size-4 text-indigo-400 shrink-0" />
|
<DatabaseIcon className="size-4 text-indigo-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-indigo-200 flex-1 truncate">
|
<span className="text-sm font-medium text-indigo-200 flex-1 truncate">
|
||||||
|
|
@ -31,6 +30,6 @@ export function GameDataNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-indigo-300/80">
|
<div className="px-3 py-2 text-xs text-indigo-300/80">
|
||||||
{fieldLabel}
|
{fieldLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,19 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { BinaryIcon } from "lucide-react";
|
import { BinaryIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function LogicNode({ data }: NodeProps) {
|
export function LogicNode({ data }: NodeProps) {
|
||||||
const d = data as { operator?: string };
|
const d = data as { operator?: string };
|
||||||
const op = d.operator ?? "AND";
|
const op = d.operator ?? "AND";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="logic" className="bg-lime-950/80 border border-lime-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="logic" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-lime-500/30">
|
||||||
<BinaryIcon className="size-4 text-lime-400 shrink-0" />
|
<BinaryIcon className="size-4 text-lime-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-lime-200 flex-1">
|
<span className="text-sm font-medium text-lime-200 flex-1">Logic</span>
|
||||||
Logic
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 text-center text-sm font-mono text-lime-300">
|
<div className="px-3 py-2 text-center text-sm font-mono text-lime-300">{op}</div>
|
||||||
{op}
|
</NodeShell>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,19 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { CalculatorIcon } from "lucide-react";
|
import { CalculatorIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function MathNode({ data }: NodeProps) {
|
export function MathNode({ data }: NodeProps) {
|
||||||
const d = data as { operator?: string };
|
const d = data as { operator?: string };
|
||||||
const op = d.operator ?? "+";
|
const op = d.operator ?? "+";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="math" className="bg-violet-950/80 border border-violet-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="math" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-500/30">
|
||||||
<CalculatorIcon className="size-4 text-violet-400 shrink-0" />
|
<CalculatorIcon className="size-4 text-violet-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-violet-200 flex-1">
|
<span className="text-sm font-medium text-violet-200 flex-1">Math</span>
|
||||||
Math
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 text-center text-lg font-mono text-violet-300">
|
<div className="px-3 py-2 text-center text-lg font-mono text-violet-300">{op}</div>
|
||||||
{op}
|
</NodeShell>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { BookOpenTextIcon, PencilIcon } from "lucide-react";
|
import { BookOpenTextIcon, PencilIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function NarrativeBeatNode({ data }: NodeProps) {
|
export function NarrativeBeatNode({ data }: NodeProps) {
|
||||||
const d = data as { title?: string; text?: string };
|
const d = data as { title?: string; text?: string };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56">
|
<NodeShell nodeType="narrativeBeat" className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56">
|
||||||
<NodeHandles nodeType="narrativeBeat" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-blue-500/30">
|
||||||
<BookOpenTextIcon className="size-4 text-blue-400 shrink-0" />
|
<BookOpenTextIcon className="size-4 text-blue-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-blue-200 flex-1 truncate">
|
<span className="text-sm font-medium text-blue-200 flex-1 truncate">
|
||||||
|
|
@ -20,6 +19,6 @@ export function NarrativeBeatNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-blue-300/80 line-clamp-3">
|
<div className="px-3 py-2 text-xs text-blue-300/80 line-clamp-3">
|
||||||
{d.text || "No description."}
|
{d.text || "No description."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { TypedHandle } from "./TypedHandle";
|
|
||||||
import { NODE_SOCKETS, type SocketDef } from "./types";
|
|
||||||
|
|
||||||
interface NodeHandlesProps {
|
|
||||||
nodeType: string;
|
|
||||||
dynamicInputs?: SocketDef[];
|
|
||||||
dynamicOutputs?: SocketDef[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NodeHandles({ nodeType, dynamicInputs, dynamicOutputs }: NodeHandlesProps) {
|
|
||||||
const config = NODE_SOCKETS[nodeType];
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
const inputs = dynamicInputs ?? config.inputs;
|
|
||||||
const outputs = dynamicOutputs ?? config.outputs;
|
|
||||||
|
|
||||||
const maxSlots = Math.max(inputs.length, outputs.length, 1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{inputs.map((socket, i) => {
|
|
||||||
const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
|
|
||||||
return (
|
|
||||||
<TypedHandle
|
|
||||||
key={socket.id}
|
|
||||||
id={socket.id}
|
|
||||||
socketType={socket.type}
|
|
||||||
label={socket.label}
|
|
||||||
isInput={true}
|
|
||||||
style={{ top: `${topPercent}%` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{outputs.map((socket, i) => {
|
|
||||||
const topPercent = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
|
|
||||||
return (
|
|
||||||
<TypedHandle
|
|
||||||
key={socket.id}
|
|
||||||
id={socket.id}
|
|
||||||
socketType={socket.type}
|
|
||||||
label={socket.label}
|
|
||||||
isInput={false}
|
|
||||||
style={{ top: `${topPercent}%` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
99
src/components/admin/narrative-flow/NodeShell.tsx
Normal file
99
src/components/admin/narrative-flow/NodeShell.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, Position } from "@xyflow/react";
|
||||||
|
import { SOCKET_COLORS, NODE_SOCKETS, type SocketDef, type SocketType } from "./types";
|
||||||
|
|
||||||
|
function SocketHandle({
|
||||||
|
socket,
|
||||||
|
isInput,
|
||||||
|
topPercent,
|
||||||
|
}: {
|
||||||
|
socket: SocketDef;
|
||||||
|
isInput: boolean;
|
||||||
|
topPercent: number;
|
||||||
|
}) {
|
||||||
|
const color = SOCKET_COLORS[socket.type];
|
||||||
|
const isFlow = socket.type === "flow";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute flex items-center gap-1"
|
||||||
|
style={{
|
||||||
|
[isInput ? "left" : "right"]: -8,
|
||||||
|
top: `${topPercent}%`,
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
flexDirection: isInput ? "row" : "row-reverse",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type={isInput ? "target" : "source"}
|
||||||
|
position={isInput ? Position.Left : Position.Right}
|
||||||
|
id={socket.id}
|
||||||
|
style={{
|
||||||
|
background: color,
|
||||||
|
width: isFlow ? 8 : 6,
|
||||||
|
height: isFlow ? 8 : 6,
|
||||||
|
borderRadius: isFlow ? 2 : "50%",
|
||||||
|
border: `1.5px solid ${color}80`,
|
||||||
|
position: "relative",
|
||||||
|
left: "auto",
|
||||||
|
right: "auto",
|
||||||
|
top: "auto",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{socket.label && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono font-medium leading-none whitespace-nowrap pointer-events-none"
|
||||||
|
style={{ color, opacity: 0.9 }}
|
||||||
|
>
|
||||||
|
{socket.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeShellProps {
|
||||||
|
nodeType: string;
|
||||||
|
dynamicInputs?: SocketDef[];
|
||||||
|
dynamicOutputs?: SocketDef[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeShell({
|
||||||
|
nodeType,
|
||||||
|
dynamicInputs,
|
||||||
|
dynamicOutputs,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: NodeShellProps) {
|
||||||
|
const config = NODE_SOCKETS[nodeType];
|
||||||
|
if (!config) return <div className={className}>{children}</div>;
|
||||||
|
|
||||||
|
const inputs = dynamicInputs ?? config.inputs;
|
||||||
|
const outputs = dynamicOutputs ?? config.outputs;
|
||||||
|
const maxSlots = Math.max(inputs.length, outputs.length, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{/* Input handles - positioned on left edge */}
|
||||||
|
{inputs.map((socket, i) => {
|
||||||
|
const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
|
||||||
|
return (
|
||||||
|
<SocketHandle key={socket.id} socket={socket} isInput={true} topPercent={top} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Output handles - positioned on right edge */}
|
||||||
|
{outputs.map((socket, i) => {
|
||||||
|
const top = maxSlots === 1 ? 50 : (i / (maxSlots - 1)) * 80 + 10;
|
||||||
|
return (
|
||||||
|
<SocketHandle key={socket.id} socket={socket} isInput={false} topPercent={top} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Content area with padding to avoid handle overlap */}
|
||||||
|
<div className="px-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,21 +2,16 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { ToggleLeftIcon } from "lucide-react";
|
import { ToggleLeftIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function NotNode(_props: NodeProps) {
|
export function NotNode(_props: NodeProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28">
|
<NodeShell nodeType="not" className="bg-red-950/80 border border-red-500/50 rounded-lg shadow-md min-w-28">
|
||||||
<NodeHandles nodeType="not" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-red-500/30">
|
||||||
<ToggleLeftIcon className="size-4 text-red-400 shrink-0" />
|
<ToggleLeftIcon className="size-4 text-red-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-red-200 flex-1">
|
<span className="text-sm font-medium text-red-200 flex-1">NOT</span>
|
||||||
NOT
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 text-xs text-red-300/80">
|
<div className="px-3 py-2 text-xs text-red-300/80">Inverts boolean</div>
|
||||||
Inverts boolean
|
</NodeShell>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { HashIcon } from "lucide-react";
|
import { HashIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function NumberNode({ data }: NodeProps) {
|
export function NumberNode({ data }: NodeProps) {
|
||||||
const d = data as { value?: number; description?: string };
|
const d = data as { value?: number; description?: string };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="number" className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="number" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-500/30">
|
||||||
<HashIcon className="size-4 text-purple-400 shrink-0" />
|
<HashIcon className="size-4 text-purple-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-purple-200 flex-1 truncate">
|
<span className="text-sm font-medium text-purple-200 flex-1 truncate">
|
||||||
|
|
@ -19,6 +18,6 @@ export function NumberNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-purple-300/80 line-clamp-2">
|
<div className="px-3 py-2 text-xs text-purple-300/80 line-clamp-2">
|
||||||
{d.description || "Numeric value"}
|
{d.description || "Numeric value"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { FlagIcon, PencilIcon } from "lucide-react";
|
import { FlagIcon, PencilIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function OutcomeNode({ data }: NodeProps) {
|
export function OutcomeNode({ data }: NodeProps) {
|
||||||
const d = data as {
|
const d = data as {
|
||||||
|
|
@ -13,8 +13,7 @@ export function OutcomeNode({ data }: NodeProps) {
|
||||||
const effects = d.effects ?? [];
|
const effects = d.effects ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56">
|
<NodeShell nodeType="outcome" className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56">
|
||||||
<NodeHandles nodeType="outcome" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-emerald-500/30">
|
||||||
<FlagIcon className="size-4 text-emerald-400 shrink-0" />
|
<FlagIcon className="size-4 text-emerald-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
|
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
|
||||||
|
|
@ -41,6 +40,6 @@ export function OutcomeNode({ data }: NodeProps) {
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { ShuffleIcon } from "lucide-react";
|
import { ShuffleIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function RandomNode({ data }: NodeProps) {
|
export function RandomNode({ data }: NodeProps) {
|
||||||
const d = data as { min?: number; max?: number; description?: string };
|
const d = data as { min?: number; max?: number; description?: string };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="random" className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="random" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-pink-500/30">
|
||||||
<ShuffleIcon className="size-4 text-pink-400 shrink-0" />
|
<ShuffleIcon className="size-4 text-pink-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-pink-200 flex-1 truncate">
|
<span className="text-sm font-medium text-pink-200 flex-1 truncate">
|
||||||
|
|
@ -19,6 +18,6 @@ export function RandomNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-pink-300/80 line-clamp-2">
|
<div className="px-3 py-2 text-xs text-pink-300/80 line-clamp-2">
|
||||||
{d.description || "Random number generator"}
|
{d.description || "Random number generator"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,20 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { ListOrderedIcon } from "lucide-react";
|
import { ListOrderedIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function SequenceNode(_props: NodeProps) {
|
export function SequenceNode(_props: NodeProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="sequence" className="bg-fuchsia-950/80 border border-fuchsia-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="sequence" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-fuchsia-500/30">
|
||||||
<ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" />
|
<ListOrderedIcon className="size-4 text-fuchsia-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-fuchsia-200 flex-1">
|
<span className="text-sm font-medium text-fuchsia-200 flex-1">Sequence</span>
|
||||||
Sequence
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 flex justify-between text-xs text-fuchsia-300/80">
|
<div className="px-3 py-2 flex justify-between text-xs text-fuchsia-300/80">
|
||||||
<span>1</span>
|
<span>1</span>
|
||||||
<span>2</span>
|
<span>2</span>
|
||||||
<span>3</span>
|
<span>3</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { TypeIcon } from "lucide-react";
|
import { TypeIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function StringNode({ data }: NodeProps) {
|
export function StringNode({ data }: NodeProps) {
|
||||||
const d = data as { value?: string; description?: string };
|
const d = data as { value?: string; description?: string };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32">
|
<NodeShell nodeType="string" className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32">
|
||||||
<NodeHandles nodeType="string" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-teal-500/30">
|
||||||
<TypeIcon className="size-4 text-teal-400 shrink-0" />
|
<TypeIcon className="size-4 text-teal-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-teal-200 flex-1 truncate">
|
<span className="text-sm font-medium text-teal-200 flex-1 truncate">
|
||||||
|
|
@ -19,6 +18,6 @@ export function StringNode({ data }: NodeProps) {
|
||||||
<div className="px-3 py-2 text-xs text-teal-300/80 line-clamp-2">
|
<div className="px-3 py-2 text-xs text-teal-300/80 line-clamp-2">
|
||||||
{d.description || "String value"}
|
{d.description || "String value"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Handle, Position } from "@xyflow/react";
|
|
||||||
import { SOCKET_COLORS, type SocketType } from "./types";
|
|
||||||
|
|
||||||
interface TypedHandleProps {
|
|
||||||
id: string;
|
|
||||||
socketType: SocketType;
|
|
||||||
isInput: boolean;
|
|
||||||
label?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TypedHandle({ id, socketType, isInput, label, style }: TypedHandleProps) {
|
|
||||||
const color = SOCKET_COLORS[socketType];
|
|
||||||
const isFlow = socketType === "flow";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute flex items-center gap-1"
|
|
||||||
style={{
|
|
||||||
[isInput ? "left" : "right"]: -4,
|
|
||||||
top: style?.top ?? "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
flexDirection: isInput ? "row" : "row-reverse",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Handle
|
|
||||||
type={isInput ? "target" : "source"}
|
|
||||||
position={isInput ? Position.Left : Position.Right}
|
|
||||||
id={id}
|
|
||||||
style={{
|
|
||||||
background: color,
|
|
||||||
width: isFlow ? 8 : 6,
|
|
||||||
height: isFlow ? 8 : 6,
|
|
||||||
borderRadius: isFlow ? 2 : "50%",
|
|
||||||
border: `1.5px solid ${color}60`,
|
|
||||||
position: "relative",
|
|
||||||
left: "auto",
|
|
||||||
right: "auto",
|
|
||||||
top: "auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label && (
|
|
||||||
<span
|
|
||||||
className="text-[8px] font-mono leading-none whitespace-nowrap pointer-events-none opacity-70"
|
|
||||||
style={{ color }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
import { type NodeProps } from "@xyflow/react";
|
import { type NodeProps } from "@xyflow/react";
|
||||||
import { TimerIcon } from "lucide-react";
|
import { TimerIcon } from "lucide-react";
|
||||||
import { NodeHandles } from "./NodeHandles";
|
import { NodeShell } from "./NodeShell";
|
||||||
|
|
||||||
export function WaitNode({ data }: NodeProps) {
|
export function WaitNode({ data }: NodeProps) {
|
||||||
const d = data as { ticks?: number; input?: unknown };
|
const d = data as { ticks?: number; input?: unknown };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48">
|
<NodeShell nodeType="wait" className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48">
|
||||||
<NodeHandles nodeType="wait" />
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-yellow-500/30">
|
||||||
<TimerIcon className="size-4 text-yellow-400 shrink-0" />
|
<TimerIcon className="size-4 text-yellow-400 shrink-0" />
|
||||||
<span className="text-sm font-medium text-yellow-200 flex-1 truncate">
|
<span className="text-sm font-medium text-yellow-200 flex-1 truncate">
|
||||||
|
|
@ -21,6 +20,6 @@ export function WaitNode({ data }: NodeProps) {
|
||||||
? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}`
|
? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}`
|
||||||
: "No input"}
|
: "No input"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NodeShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue