From 4a573f7cfc7fff350bfc52e55dfe7b0437932c5f Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 28 Jul 2026 17:44:46 -0400 Subject: [PATCH] feat: add Wait and primitive node types to NarrativeFlowEditor - New node types: - Wait: delays execution for N ticks, supports optional input - Number: stores a numeric value - String: stores a string value - Random: generates random numbers in range [min, max] - Boolean: stores true/false flag - Date: stores a date value - Color-coded nodes matching React Flow palette: - Wait: yellow - Number: purple - String: teal - Random: pink - Boolean: emerald - Date: cyan - NodeEditDialog now handles all new types with appropriate fields - Toolbar updated with color-matched buttons --- .../admin/narrative-flow/BooleanNode.tsx | 24 ++ .../admin/narrative-flow/DateNode.tsx | 24 ++ .../narrative-flow/NarrativeFlowEditor.tsx | 127 ++++++++--- .../admin/narrative-flow/NodeEditDialog.tsx | 212 +++++++++++++++++- .../admin/narrative-flow/NumberNode.tsx | 24 ++ .../admin/narrative-flow/RandomNode.tsx | 24 ++ .../admin/narrative-flow/StringNode.tsx | 24 ++ .../admin/narrative-flow/WaitNode.tsx | 24 ++ 8 files changed, 454 insertions(+), 29 deletions(-) create mode 100644 src/components/admin/narrative-flow/BooleanNode.tsx create mode 100644 src/components/admin/narrative-flow/DateNode.tsx create mode 100644 src/components/admin/narrative-flow/NumberNode.tsx create mode 100644 src/components/admin/narrative-flow/RandomNode.tsx create mode 100644 src/components/admin/narrative-flow/StringNode.tsx create mode 100644 src/components/admin/narrative-flow/WaitNode.tsx diff --git a/src/components/admin/narrative-flow/BooleanNode.tsx b/src/components/admin/narrative-flow/BooleanNode.tsx new file mode 100644 index 0000000..a8cd6b3 --- /dev/null +++ b/src/components/admin/narrative-flow/BooleanNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { CheckCircleIcon } from "lucide-react"; + +export function BooleanNode({ data }: NodeProps) { + const d = data as { value?: boolean; description?: string }; + + return ( +
+ +
+ + + {(d.value ?? false) ? "true" : "false"} + +
+
+ {d.description || "Boolean flag"} +
+ +
+ ); +} diff --git a/src/components/admin/narrative-flow/DateNode.tsx b/src/components/admin/narrative-flow/DateNode.tsx new file mode 100644 index 0000000..5745da1 --- /dev/null +++ b/src/components/admin/narrative-flow/DateNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { CalendarIcon } from "lucide-react"; + +export function DateNode({ data }: NodeProps) { + const d = data as { value?: string; description?: string }; + + return ( +
+ +
+ + + {d.value ? new Date(d.value).toLocaleDateString() : "--"} + +
+
+ {d.description || "Date value"} +
+ +
+ ); +} diff --git a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx index 18dd5d7..93064d6 100644 --- a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx +++ b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx @@ -22,12 +22,24 @@ import { useField } from "@payloadcms/ui"; import { NarrativeBeatNode } from "./NarrativeBeatNode"; import { ChoiceNode } from "./ChoiceNode"; import { OutcomeNode } from "./OutcomeNode"; +import { WaitNode } from "./WaitNode"; +import { NumberNode } from "./NumberNode"; +import { StringNode } from "./StringNode"; +import { RandomNode } from "./RandomNode"; +import { BooleanNode } from "./BooleanNode"; +import { DateNode } from "./DateNode"; import { NodeEditDialog } from "./NodeEditDialog"; const nodeTypes = { narrativeBeat: NarrativeBeatNode, choice: ChoiceNode, outcome: OutcomeNode, + wait: WaitNode, + number: NumberNode, + string: StringNode, + random: RandomNode, + boolean: BooleanNode, + date: DateNode, }; type FlowData = { nodes: Node[]; edges: Edge[] }; @@ -123,7 +135,18 @@ export function NarrativeFlowEditor() { const selectedNode = editingNodeId ? nodes.find((n) => n.id === editingNodeId) ?? null : null; const addNode = useCallback( - (type: "narrativeBeat" | "choice" | "outcome") => { + ( + type: + | "narrativeBeat" + | "choice" + | "outcome" + | "wait" + | "number" + | "string" + | "random" + | "boolean" + | "date", + ) => { const id = crypto.randomUUID(); const pos = { x: Math.random() * 400, y: Math.random() * 300 }; const newNode: Node = { @@ -135,7 +158,19 @@ export function NarrativeFlowEditor() { ? { question: "New choice?", options: [] } : type === "outcome" ? { title: "New outcome", text: "", effects: [] } - : { title: "New beat", text: "" }, + : type === "wait" + ? { ticks: 10 } + : type === "number" + ? { value: 0 } + : type === "string" + ? { value: "" } + : type === "random" + ? { min: 0, max: 100 } + : type === "boolean" + ? { value: false } + : type === "date" + ? { value: "" } + : { title: "New beat", text: "" }, }; setNodes((nds) => [...nds, newNode]); }, @@ -145,32 +180,72 @@ export function NarrativeFlowEditor() { const buttonClass = "px-3 py-1.5 text-xs font-medium rounded-md border transition-colors cursor-pointer"; + const toolbarButtons = ( +
+ + + + + + + + + + + {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge + {edges.length !== 1 ? "s" : ""} + +
+ ); + return (
-
- - - - - {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge - {edges.length !== 1 ? "s" : ""} - -
+ {toolbarButtons}
= ({
); + case "wait": { + const ticks = (nodeData.ticks as number) ?? 10; + return ( +
+
+ + updateField("ticks", parseInt(e.target.value, 10) || 10)} + placeholder="Number of ticks to wait" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ + updateField("input", e.target.value)} + placeholder="Value to pass through" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ ); + } + + case "number": { + const value = (nodeData.value as number) ?? 0; + return ( +
+
+ + updateField("value", parseFloat(e.target.value) || 0)} + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ + updateField("description", e.target.value)} + placeholder="What does this number represent?" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ ); + } + + case "string": { + const value = (nodeData.value as string) ?? ""; + return ( +
+
+ + updateField("value", e.target.value)} + placeholder="String value" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ + updateField("description", e.target.value)} + placeholder="What does this string represent?" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ ); + } + + case "random": { + const min = (nodeData.min as number) ?? 0; + const max = (nodeData.max as number) ?? 100; + return ( +
+
+ + updateField("min", parseInt(e.target.value, 10) || 0)} + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ + updateField("max", parseInt(e.target.value, 10) || 100)} + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ + updateField("description", e.target.value)} + placeholder="What range to randomize?" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ ); + } + + case "boolean": { + const value = (nodeData.value as boolean) ?? false; + return ( +
+
+ +
+ + +
+
+
+ + updateField("description", e.target.value)} + placeholder="What flag does this represent?" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ ); + } + + case "date": { + const value = (nodeData.value as string) ?? ""; + return ( +
+
+ + updateField("value", e.target.value)} + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ + updateField("description", e.target.value)} + placeholder="What event does this date represent?" + className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100" + /> +
+
+ ); + } + case "choice": { const options = (nodeData.options as Array<{ id: string; label: string }>) ?? []; return ( @@ -241,7 +435,19 @@ export const NodeEditDialog: FC = ({ ? "Choice" : nodeType === "outcome" ? "Outcome" - : "Node"; + : nodeType === "wait" + ? "Wait" + : nodeType === "number" + ? "Number" + : nodeType === "string" + ? "String" + : nodeType === "random" + ? "Random" + : nodeType === "boolean" + ? "Boolean" + : nodeType === "date" + ? "Date" + : "Node"; return ( @@ -251,10 +457,10 @@ export const NodeEditDialog: FC = ({ Edit {typeLabel} onOpenChange(false)} - className="hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 rounded-full" + className="p-2 -m-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 rounded-full" aria-label="Close" > - + diff --git a/src/components/admin/narrative-flow/NumberNode.tsx b/src/components/admin/narrative-flow/NumberNode.tsx new file mode 100644 index 0000000..fadf85d --- /dev/null +++ b/src/components/admin/narrative-flow/NumberNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { HashIcon } from "lucide-react"; + +export function NumberNode({ data }: NodeProps) { + const d = data as { value?: number; description?: string }; + + return ( +
+ +
+ + + {d.value ?? "0"} + +
+
+ {d.description || "Numeric value"} +
+ +
+ ); +} diff --git a/src/components/admin/narrative-flow/RandomNode.tsx b/src/components/admin/narrative-flow/RandomNode.tsx new file mode 100644 index 0000000..27f8421 --- /dev/null +++ b/src/components/admin/narrative-flow/RandomNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { ShuffleIcon } from "lucide-react"; + +export function RandomNode({ data }: NodeProps) { + const d = data as { min?: number; max?: number; description?: string }; + + return ( +
+ +
+ + + {d.min ?? 0} - {d.max ?? 100} + +
+
+ {d.description || "Random number generator"} +
+ +
+ ); +} diff --git a/src/components/admin/narrative-flow/StringNode.tsx b/src/components/admin/narrative-flow/StringNode.tsx new file mode 100644 index 0000000..43eaed5 --- /dev/null +++ b/src/components/admin/narrative-flow/StringNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { TypeIcon } from "lucide-react"; + +export function StringNode({ data }: NodeProps) { + const d = data as { value?: string; description?: string }; + + return ( +
+ +
+ + + {d.value ?? "string"} + +
+
+ {d.description || "String value"} +
+ +
+ ); +} diff --git a/src/components/admin/narrative-flow/WaitNode.tsx b/src/components/admin/narrative-flow/WaitNode.tsx new file mode 100644 index 0000000..dffdf5b --- /dev/null +++ b/src/components/admin/narrative-flow/WaitNode.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { Handle, Position, type NodeProps } from "@xyflow/react"; +import { TimerIcon } from "lucide-react"; + +export function WaitNode({ data }: NodeProps) { + const d = data as { ticks?: number; input?: unknown }; + + return ( +
+ +
+ + + Wait {d.ticks ?? "10"} ticks + +
+
+ {d.input !== undefined ? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}` : "No input"} +
+ +
+ ); +}