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
This commit is contained in:
parent
9651ad83ae
commit
4a573f7cfc
8 changed files with 454 additions and 29 deletions
24
src/components/admin/narrative-flow/BooleanNode.tsx
Normal file
24
src/components/admin/narrative-flow/BooleanNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-28">
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-emerald-400" />
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
|
||||||
|
{(d.value ?? false) ? "true" : "false"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-emerald-300/80 line-clamp-2">
|
||||||
|
{d.description || "Boolean flag"}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-emerald-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/narrative-flow/DateNode.tsx
Normal file
24
src/components/admin/narrative-flow/DateNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-cyan-950/80 border border-cyan-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-cyan-400" />
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-medium text-cyan-200 flex-1 truncate">
|
||||||
|
{d.value ? new Date(d.value).toLocaleDateString() : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-cyan-300/80 line-clamp-2">
|
||||||
|
{d.description || "Date value"}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-cyan-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,12 +22,24 @@ import { useField } from "@payloadcms/ui";
|
||||||
import { NarrativeBeatNode } from "./NarrativeBeatNode";
|
import { NarrativeBeatNode } from "./NarrativeBeatNode";
|
||||||
import { ChoiceNode } from "./ChoiceNode";
|
import { ChoiceNode } from "./ChoiceNode";
|
||||||
import { OutcomeNode } from "./OutcomeNode";
|
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";
|
import { NodeEditDialog } from "./NodeEditDialog";
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
narrativeBeat: NarrativeBeatNode,
|
narrativeBeat: NarrativeBeatNode,
|
||||||
choice: ChoiceNode,
|
choice: ChoiceNode,
|
||||||
outcome: OutcomeNode,
|
outcome: OutcomeNode,
|
||||||
|
wait: WaitNode,
|
||||||
|
number: NumberNode,
|
||||||
|
string: StringNode,
|
||||||
|
random: RandomNode,
|
||||||
|
boolean: BooleanNode,
|
||||||
|
date: DateNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
type FlowData = { nodes: Node[]; edges: Edge[] };
|
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 selectedNode = editingNodeId ? nodes.find((n) => n.id === editingNodeId) ?? null : null;
|
||||||
|
|
||||||
const addNode = useCallback(
|
const addNode = useCallback(
|
||||||
(type: "narrativeBeat" | "choice" | "outcome") => {
|
(
|
||||||
|
type:
|
||||||
|
| "narrativeBeat"
|
||||||
|
| "choice"
|
||||||
|
| "outcome"
|
||||||
|
| "wait"
|
||||||
|
| "number"
|
||||||
|
| "string"
|
||||||
|
| "random"
|
||||||
|
| "boolean"
|
||||||
|
| "date",
|
||||||
|
) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const pos = { x: Math.random() * 400, y: Math.random() * 300 };
|
const pos = { x: Math.random() * 400, y: Math.random() * 300 };
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
|
|
@ -135,6 +158,18 @@ export function NarrativeFlowEditor() {
|
||||||
? { question: "New choice?", options: [] }
|
? { question: "New choice?", options: [] }
|
||||||
: type === "outcome"
|
: type === "outcome"
|
||||||
? { title: "New outcome", text: "", effects: [] }
|
? { title: "New outcome", text: "", effects: [] }
|
||||||
|
: 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: "" },
|
: { title: "New beat", text: "" },
|
||||||
};
|
};
|
||||||
setNodes((nds) => [...nds, newNode]);
|
setNodes((nds) => [...nds, newNode]);
|
||||||
|
|
@ -145,8 +180,7 @@ export function NarrativeFlowEditor() {
|
||||||
const buttonClass =
|
const buttonClass =
|
||||||
"px-3 py-1.5 text-xs font-medium rounded-md border transition-colors cursor-pointer";
|
"px-3 py-1.5 text-xs font-medium rounded-md border transition-colors cursor-pointer";
|
||||||
|
|
||||||
return (
|
const toolbarButtons = (
|
||||||
<div className="flex flex-col gap-2 w-full" style={{ height: 600 }}>
|
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700">
|
<div className="flex items-center gap-2 px-2 py-1.5 bg-zinc-900 rounded-t-md border border-zinc-700">
|
||||||
<button
|
<button
|
||||||
className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`}
|
className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`}
|
||||||
|
|
@ -166,11 +200,52 @@ export function NarrativeFlowEditor() {
|
||||||
>
|
>
|
||||||
+ Outcome
|
+ Outcome
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${buttonClass} bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-900`}
|
||||||
|
onClick={() => addNode("wait")}
|
||||||
|
>
|
||||||
|
+ Wait
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${buttonClass} bg-purple-950 border-purple-700 text-purple-200 hover:bg-purple-900`}
|
||||||
|
onClick={() => addNode("number")}
|
||||||
|
>
|
||||||
|
+ Number
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${buttonClass} bg-teal-950 border-teal-700 text-teal-200 hover:bg-teal-900`}
|
||||||
|
onClick={() => addNode("string")}
|
||||||
|
>
|
||||||
|
+ String
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${buttonClass} bg-pink-950 border-pink-700 text-pink-200 hover:bg-pink-900`}
|
||||||
|
onClick={() => addNode("random")}
|
||||||
|
>
|
||||||
|
+ Random
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${buttonClass} bg-emerald-950/30 border-emerald-700/50 text-emerald-200 hover:bg-emerald-900/40`}
|
||||||
|
onClick={() => addNode("boolean")}
|
||||||
|
>
|
||||||
|
+ Boolean
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${buttonClass} bg-cyan-950 border-cyan-700 text-cyan-200 hover:bg-cyan-900`}
|
||||||
|
onClick={() => addNode("date")}
|
||||||
|
>
|
||||||
|
+ Date
|
||||||
|
</button>
|
||||||
<span className="ml-auto text-[10px] text-zinc-500">
|
<span className="ml-auto text-[10px] text-zinc-500">
|
||||||
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge
|
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge
|
||||||
{edges.length !== 1 ? "s" : ""}
|
{edges.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 w-full" style={{ height: 600 }}>
|
||||||
|
{toolbarButtons}
|
||||||
<div className="flex-1 border border-zinc-700 rounded-b-md overflow-hidden">
|
<div className="flex-1 border border-zinc-700 rounded-b-md overflow-hidden">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,200 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "wait": {
|
||||||
|
const ticks = (nodeData.ticks as number) ?? 10;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wait-ticks">Wait Ticks</Label>
|
||||||
|
<Input
|
||||||
|
id="wait-ticks"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={ticks}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wait-input">Input (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="wait-input"
|
||||||
|
value={(nodeData.input as string) ?? ""}
|
||||||
|
onChange={(e) => updateField("input", e.target.value)}
|
||||||
|
placeholder="Value to pass through"
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "number": {
|
||||||
|
const value = (nodeData.value as number) ?? 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="number-value">Value</Label>
|
||||||
|
<Input
|
||||||
|
id="number-value"
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateField("value", parseFloat(e.target.value) || 0)}
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="number-desc">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="number-desc"
|
||||||
|
value={(nodeData.description as string) ?? ""}
|
||||||
|
onChange={(e) => updateField("description", e.target.value)}
|
||||||
|
placeholder="What does this number represent?"
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "string": {
|
||||||
|
const value = (nodeData.value as string) ?? "";
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="string-value">Value</Label>
|
||||||
|
<Input
|
||||||
|
id="string-value"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateField("value", e.target.value)}
|
||||||
|
placeholder="String value"
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="string-desc">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="string-desc"
|
||||||
|
value={(nodeData.description as string) ?? ""}
|
||||||
|
onChange={(e) => updateField("description", e.target.value)}
|
||||||
|
placeholder="What does this string represent?"
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "random": {
|
||||||
|
const min = (nodeData.min as number) ?? 0;
|
||||||
|
const max = (nodeData.max as number) ?? 100;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="random-min">Min</Label>
|
||||||
|
<Input
|
||||||
|
id="random-min"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={min}
|
||||||
|
onChange={(e) => updateField("min", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="random-max">Max</Label>
|
||||||
|
<Input
|
||||||
|
id="random-max"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={max}
|
||||||
|
onChange={(e) => updateField("max", parseInt(e.target.value, 10) || 100)}
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="random-desc">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="random-desc"
|
||||||
|
value={(nodeData.description as string) ?? ""}
|
||||||
|
onChange={(e) => updateField("description", e.target.value)}
|
||||||
|
placeholder="What range to randomize?"
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "boolean": {
|
||||||
|
const value = (nodeData.value as boolean) ?? false;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Value</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateField("value", true)}
|
||||||
|
className="dark:bg-emerald-600 dark:hover:bg-emerald-700 dark:text-white"
|
||||||
|
>
|
||||||
|
True
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateField("value", false)}
|
||||||
|
className="dark:bg-zinc-700 dark:hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
False
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="boolean-desc">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="boolean-desc"
|
||||||
|
value={(nodeData.description as string) ?? ""}
|
||||||
|
onChange={(e) => updateField("description", e.target.value)}
|
||||||
|
placeholder="What flag does this represent?"
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "date": {
|
||||||
|
const value = (nodeData.value as string) ?? "";
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date-value">Date</Label>
|
||||||
|
<Input
|
||||||
|
id="date-value"
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => updateField("value", e.target.value)}
|
||||||
|
className="dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date-desc">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="date-desc"
|
||||||
|
value={(nodeData.description as string) ?? ""}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "choice": {
|
case "choice": {
|
||||||
const options = (nodeData.options as Array<{ id: string; label: string }>) ?? [];
|
const options = (nodeData.options as Array<{ id: string; label: string }>) ?? [];
|
||||||
return (
|
return (
|
||||||
|
|
@ -241,6 +435,18 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
? "Choice"
|
? "Choice"
|
||||||
: nodeType === "outcome"
|
: nodeType === "outcome"
|
||||||
? "Outcome"
|
? "Outcome"
|
||||||
|
: nodeType === "wait"
|
||||||
|
? "Wait"
|
||||||
|
: nodeType === "number"
|
||||||
|
? "Number"
|
||||||
|
: nodeType === "string"
|
||||||
|
? "String"
|
||||||
|
: nodeType === "random"
|
||||||
|
? "Random"
|
||||||
|
: nodeType === "boolean"
|
||||||
|
? "Boolean"
|
||||||
|
: nodeType === "date"
|
||||||
|
? "Date"
|
||||||
: "Node";
|
: "Node";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -251,10 +457,10 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
<span>Edit {typeLabel}</span>
|
<span>Edit {typeLabel}</span>
|
||||||
<span
|
<span
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => 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"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<XIcon className="size-8 text-zinc-500" />
|
<XIcon className="size-4 text-zinc-500" />
|
||||||
</span>
|
</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
|
||||||
24
src/components/admin/narrative-flow/NumberNode.tsx
Normal file
24
src/components/admin/narrative-flow/NumberNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-purple-950/80 border border-purple-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-purple-400" />
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-medium text-purple-200 flex-1 truncate">
|
||||||
|
{d.value ?? "0"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-purple-300/80 line-clamp-2">
|
||||||
|
{d.description || "Numeric value"}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-purple-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/narrative-flow/RandomNode.tsx
Normal file
24
src/components/admin/narrative-flow/RandomNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-pink-950/80 border border-pink-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-pink-400" />
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-medium text-pink-200 flex-1 truncate">
|
||||||
|
{d.min ?? 0} - {d.max ?? 100}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-pink-300/80 line-clamp-2">
|
||||||
|
{d.description || "Random number generator"}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-pink-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/narrative-flow/StringNode.tsx
Normal file
24
src/components/admin/narrative-flow/StringNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-teal-950/80 border border-teal-500/50 rounded-lg shadow-md min-w-32">
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-teal-400" />
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-medium text-teal-200 flex-1 truncate">
|
||||||
|
{d.value ?? "string"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-teal-300/80 line-clamp-2">
|
||||||
|
{d.description || "String value"}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-teal-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/narrative-flow/WaitNode.tsx
Normal file
24
src/components/admin/narrative-flow/WaitNode.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-yellow-950/80 border border-yellow-500/50 rounded-lg shadow-md min-w-48">
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-yellow-400" />
|
||||||
|
<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" />
|
||||||
|
<span className="text-sm font-medium text-yellow-200 flex-1 truncate">
|
||||||
|
Wait {d.ticks ?? "10"} ticks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 text-xs text-yellow-300/80 line-clamp-2">
|
||||||
|
{d.input !== undefined ? `Input: ${String(d.input).substring(0, 50)}${String(d.input).length > 50 ? "..." : ""}` : "No input"}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue