1
0
Fork 0

feat: narrative events flowchart editor with React Flow

- NarrativeEvents collection redesigned with flowData JSON (React Flow format)
- Custom admin field component: NarrativeFlowEditor with @xyflow/react
- Three node types: NarrativeBeat (blue), Choice (amber), Outcome (emerald)
- NodeEditDialog with type-specific forms replacing prompt() editing
- Right-click context menu (Delete node/edge with Radix primitives)
- Dynamic handles on Choice nodes (one per option)
- Dark-themed canvas with MiniMap, Controls, and animated edges
- All changes sync bidirectionally with Payload form state
- AGENTS.md updated with architecture docs
This commit is contained in:
Jason Fraley 2026-07-28 16:26:57 -04:00
parent 6efb1e1d65
commit 103340271b
12 changed files with 1025 additions and 8 deletions

View file

@ -98,6 +98,23 @@ Access control helpers live in `src/utils/access-control/` (isRole, hasRoles). R
### Non-structure events
The `EventLedger` currently filters by `structure.id`. For event logs scoped to other entities (e.g., faction finance ledger), a new ledger variant would need to be created that queries by `targetCollection` + `targetId` instead.
## Narrative Events System
`src/collections/world/NarrativeEvents.ts` — `narrative-events` collection. A flowchart-based narrative event editor using `@xyflow/react` (React Flow) in the Payload admin panel.
- **Schema**: `name` (text, title), `summary` (textarea), `flowData` (JSON — React Flow nodes + edges), `triggers` (group with type select + optional condition JSON).
- **Access**: developer-only (create/update/delete/read).
- **Flow editor**: custom admin field component at `src/components/admin/narrative-flow/NarrativeFlowEditor.tsx`. Renders a React Flow canvas with a toolbar to add node types.
- **Node types** (custom React Flow nodes in `src/components/admin/narrative-flow/`):
- **NarrativeBeat** (`NarrativeBeatNode.tsx`) — blue, story exposition. One source handle (bottom) + one target handle (top). Fields: `title`, `text`.
- **Choice** (`ChoiceNode.tsx`) — amber, player decision point. One target handle (top), one source handle per option (right side). Fields: `question`, `options[]` (each with `id`, `label`).
- **Outcome** (`OutcomeNode.tsx`) — emerald, terminal result. Target handle only. Fields: `title`, `text`, `effects[]` (each with `type`, `value`).
- **Context menu**: right-click any node/edge to open a Radix context menu with a "Delete" action. Deleting a node also removes connected edges.
- **Data flow**: changes to nodes/edges sync to Payload's form state via `useField().setValue()`. Serialization uses `JSON.stringify` diffing to avoid loops.
- **Editing**: double-click or click the edit icon on a node to edit its properties via node edit dialog (modal with type-specific fields).
- **Dependencies**: `@xyflow/react` (v12), `radix-ui` (context-menu primitives), `lucide-react` (icons).
- To add a new node type, create the component + register it in the `nodeTypes` object in `NarrativeFlowEditor.tsx`.
## Auth
Username-based login (no email login). Users log in via Payload admin with `username` only.

View file

@ -24,6 +24,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.18",
"@types/nodemailer": "^8.0.1",
"@xyflow/react": "^12.11.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^10.1.0",
@ -847,6 +848,18 @@
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "4.0.2" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "2.1.0" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@ -959,6 +972,10 @@
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="],
"@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="],
"acorn": ["acorn@8.12.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@ -1071,6 +1088,8 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@ -1107,6 +1126,24 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "4.0.0", "whatwg-url": "15.1.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="],
@ -2097,6 +2134,8 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "4.2.1" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "2.2.0", "tslib": "2.8.1" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],

View file

@ -43,6 +43,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/postcss": "^4.1.18",
"@types/nodemailer": "^8.0.1",
"@xyflow/react": "^12.11.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cross-env": "^10.1.0",

View file

@ -23,6 +23,7 @@ import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { default as default_0edde40a6ccd30c893b1227360f69360 } from '@/components/static/TickDurationDescription'
import { default as default_2d468cc5cf0b647ffca3201592c20825 } from '@/components/static/WarningMessage'
import { default as default_7fa52484aec46e988e9cfb5e86c0c759 } from '@/components/admin/narrative-flow/NarrativeFlowEditor'
import { default as default_2f6c866ab4d4d51b3789f090b84b85c7 } from '@/components/static/VersionOverlay'
import { default as default_508743824696caae2894ebf34c2e68bb } from '@/components/graphics/AppIcon'
import { default as default_e752fee78378294ff03f91dba0a75a04 } from '@/components/graphics/AppLogo'
@ -54,6 +55,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/components/static/TickDurationDescription#default": default_0edde40a6ccd30c893b1227360f69360,
"@/components/static/WarningMessage#default": default_2d468cc5cf0b647ffca3201592c20825,
"@/components/admin/narrative-flow/NarrativeFlowEditor#default": default_7fa52484aec46e988e9cfb5e86c0c759,
"@/components/static/VersionOverlay#default": default_2f6c866ab4d4d51b3789f090b84b85c7,
"@/components/graphics/AppIcon#default": default_508743824696caae2894ebf34c2e68bb,
"@/components/graphics/AppLogo#default": default_e752fee78378294ff03f91dba0a75a04,

View file

@ -5,6 +5,7 @@ export const NarrativeEvents: CollectionConfig = {
slug: "narrative-events",
admin: {
group: "World",
useAsTitle: "name",
},
access: {
create: isDeveloper,
@ -17,26 +18,70 @@ export const NarrativeEvents: CollectionConfig = {
name: "name",
type: "text",
required: true,
admin: {
description: "The title of this narrative event chain.",
},
},
{
name: "summary",
type: "textarea",
required: true,
admin: {
description: "A brief summary of what this event is about.",
},
},
{
type: "tabs",
tabs: [
{
label: "Event Setup",
description:
"Establish the basic requirements and conditions needed for this event to occur.",
fields: [],
label: "Flow Editor",
description: "Build the narrative flowchart by adding beats, choices, and outcomes.",
fields: [
{
name: "flowData",
type: "json",
required: true,
defaultValue: () => ({
nodes: [],
edges: [],
}),
admin: {
components: {
Field: "@/components/admin/narrative-flow/NarrativeFlowEditor",
},
},
},
],
},
{
label: "Narrative Graph",
description:
"Write the event storyline here, including choices, conditions, and outcomes!",
fields: [],
label: "Event Configuration",
description: "Configure trigger conditions and other metadata.",
fields: [
{
name: "triggers",
type: "group",
fields: [
{
name: "type",
type: "select",
options: [
{ label: "Manual", value: "manual" },
{ label: "On Mission Complete", value: "mission_complete" },
{ label: "On Timer", value: "timer" },
{ label: "On Condition", value: "condition" },
],
defaultValue: "manual",
},
{
name: "condition",
type: "json",
admin: {
description: "Optional JSON conditions for when this event can fire.",
},
},
],
},
],
},
],
},

View file

@ -0,0 +1,44 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { PencilIcon, WaypointsIcon } from "lucide-react";
export function ChoiceNode({ data }: NodeProps) {
const d = data as { question?: string; options?: Array<{ id: string; label: string }> };
const options = d.options ?? [];
return (
<div className="bg-amber-950/80 border border-amber-500/50 rounded-lg shadow-md min-w-56">
<Handle type="target" position={Position.Top} className="!bg-amber-400" />
<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" />
<span className="text-sm font-medium text-amber-200 flex-1 truncate">
{d.question || "Untitled Choice"}
</span>
<PencilIcon className="size-3 text-amber-500/40 shrink-0" />
</div>
<div className="px-3 py-2 space-y-1">
{options.length === 0 ? (
<span className="text-xs text-amber-400/60 italic">No options defined.</span>
) : (
options.map((opt) => (
<div
key={opt.id}
className="relative flex items-center gap-2 text-xs text-amber-300/80 bg-amber-950/40 rounded px-2 py-1"
>
<span className="w-1 h-1 rounded-full bg-amber-400 shrink-0" />
<span className="flex-1 truncate">{opt.label}</span>
<Handle
type="source"
position={Position.Right}
id={opt.id}
className="!bg-amber-400 !size-2 !min-w-0 !min-h-0 !border-0"
/>
</div>
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { BookOpenTextIcon, PencilIcon } from "lucide-react";
export function NarrativeBeatNode({ data }: NodeProps) {
const d = data as { title?: string; text?: string };
return (
<div className="bg-blue-950/80 border border-blue-500/50 rounded-lg shadow-md min-w-56">
<Handle type="target" position={Position.Top} className="!bg-blue-400" />
<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" />
<span className="text-sm font-medium text-blue-200 flex-1 truncate">
{d.title || "Untitled Beat"}
</span>
<PencilIcon className="size-3 text-blue-500/40 shrink-0" />
</div>
<div className="px-3 py-2 text-xs text-blue-300/80 line-clamp-3">
{d.text || "No description."}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-blue-400" />
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
type Node,
type Edge,
type Connection,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { Trash2Icon } from "lucide-react";
import { useField } from "@payloadcms/ui";
import { NarrativeBeatNode } from "./NarrativeBeatNode";
import { ChoiceNode } from "./ChoiceNode";
import { OutcomeNode } from "./OutcomeNode";
import { NodeEditDialog } from "./NodeEditDialog";
const nodeTypes = {
narrativeBeat: NarrativeBeatNode,
choice: ChoiceNode,
outcome: OutcomeNode,
};
type FlowData = { nodes: Node[]; edges: Edge[] };
const defaultFlow: FlowData = { nodes: [], edges: [] };
type ContextMenuState = {
x: number;
y: number;
nodeId?: string;
edgeId?: string;
} | null;
export function NarrativeFlowEditor() {
const { value, setValue } = useField<FlowData>();
const initialData: FlowData =
value && typeof value === "object" && Array.isArray((value as FlowData).nodes)
? (value as FlowData)
: defaultFlow;
const [nodes, setNodes, onNodesChange] = useNodesState(initialData.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialData.edges);
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const lastSaved = useRef<string>("");
useEffect(() => {
const flow = { nodes, edges };
const serialized = JSON.stringify(flow);
if (serialized !== lastSaved.current) {
lastSaved.current = serialized;
setValue(flow);
}
}, [nodes, edges, setValue]);
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) => addEdge({ ...connection, animated: true }, eds));
},
[setEdges],
);
const onNodeClick = useCallback((_e: React.MouseEvent, node: Node) => {
setEditingNodeId(node.id);
}, []);
const onNodeContextMenu = useCallback(
(e: React.MouseEvent, node: Node) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id });
},
[],
);
const onEdgeContextMenu = useCallback(
(e: React.MouseEvent, edge: Edge) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, edgeId: edge.id });
},
[],
);
const onPaneContextMenu = useCallback((e: MouseEvent | React.MouseEvent) => {
e.preventDefault();
setContextMenu(null);
}, []);
const deleteSelected = useCallback(() => {
if (!contextMenu) return;
if (contextMenu.nodeId) {
setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
setEdges((eds) =>
eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId),
);
} else if (contextMenu.edgeId) {
setEdges((eds) => eds.filter((e) => e.id !== contextMenu.edgeId));
}
setContextMenu(null);
}, [contextMenu, setNodes, setEdges]);
const handleNodeSave = useCallback(
(nodeId: string, data: Record<string, unknown>) => {
setNodes((nds) =>
nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n)),
);
},
[setNodes],
);
const selectedNode = editingNodeId ? nodes.find((n) => n.id === editingNodeId) ?? null : null;
const addNode = useCallback(
(type: "narrativeBeat" | "choice" | "outcome") => {
const id = crypto.randomUUID();
const pos = { x: Math.random() * 400, y: Math.random() * 300 };
const newNode: Node = {
id,
type,
position: pos,
data:
type === "choice"
? { question: "New choice?", options: [] }
: type === "outcome"
? { title: "New outcome", text: "", effects: [] }
: { title: "New beat", text: "" },
};
setNodes((nds) => [...nds, newNode]);
},
[setNodes],
);
const buttonClass =
"px-3 py-1.5 text-xs font-medium rounded-md border transition-colors cursor-pointer";
return (
<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">
<button
className={`${buttonClass} bg-blue-950 border-blue-700 text-blue-200 hover:bg-blue-900`}
onClick={() => addNode("narrativeBeat")}
>
+ Beat
</button>
<button
className={`${buttonClass} bg-amber-950 border-amber-700 text-amber-200 hover:bg-amber-900`}
onClick={() => addNode("choice")}
>
+ Choice
</button>
<button
className={`${buttonClass} bg-emerald-950 border-emerald-700 text-emerald-200 hover:bg-emerald-900`}
onClick={() => addNode("outcome")}
>
+ Outcome
</button>
<span className="ml-auto text-[10px] text-zinc-500">
{nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge
{edges.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex-1 border border-zinc-700 rounded-b-md overflow-hidden">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onPaneContextMenu={onPaneContextMenu}
nodeTypes={nodeTypes}
fitView
colorMode="dark"
defaultEdgeOptions={{ animated: true }}
>
<Background color="#333" gap={20} />
<Controls className="!bg-zinc-900 !border-zinc-700" />
<MiniMap
className="!border-zinc-700"
style={{ background: "#18181b" }}
nodeColor={(n) =>
n.type === "choice"
? "#d97706"
: n.type === "outcome"
? "#10b981"
: "#3b82f6"
}
/>
</ReactFlow>
{contextMenu && (
<ContextMenuPrimitive.Root
open
onOpenChange={(open) => {
if (!open) setContextMenu(null);
}}
>
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
style={{ left: contextMenu.x, top: contextMenu.y, position: "fixed" }}
onInteractOutside={() => setContextMenu(null)}
className="z-50 min-w-[8rem] overflow-hidden rounded-md border border-zinc-700 bg-zinc-900 p-1 shadow-lg"
>
<ContextMenuPrimitive.Item
onClick={deleteSelected}
className="relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-400 outline-none select-none hover:bg-red-950/40"
>
<Trash2Icon className="size-4" />
Delete{contextMenu.nodeId ? " node" : contextMenu.edgeId ? " connection" : ""}
</ContextMenuPrimitive.Item>
</ContextMenuPrimitive.Content>
</ContextMenuPrimitive.Portal>
</ContextMenuPrimitive.Root>
)}
<NodeEditDialog
node={selectedNode}
open={editingNodeId !== null}
onOpenChange={(open) => {
if (!open) setEditingNodeId(null);
}}
onSave={handleNodeSave}
/>
</div>
</div>
);
}
export default NarrativeFlowEditor;

View file

@ -0,0 +1,263 @@
"use client";
import { useEffect, useState, type FC } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Node } from "@xyflow/react";
interface NodeEditDialogProps {
node: Node | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (nodeId: string, data: Record<string, unknown>) => void;
}
export const NodeEditDialog: FC<NodeEditDialogProps> = ({
node,
open,
onOpenChange,
onSave,
}) => {
const [formData, setFormData] = useState<Record<string, unknown>>({});
useEffect(() => {
if (node && open) {
setFormData({ ...node.data });
}
}, [node?.id, open]);
if (!node) return null;
const nodeData = formData;
const nodeType = node.type as string;
const updateField = (key: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
onSave(node.id, formData);
setFormData({});
onOpenChange(false);
};
const handleCancel = () => {
setFormData({});
onOpenChange(false);
};
const renderFields = () => {
switch (nodeType) {
case "narrativeBeat":
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="beat-title">Title</Label>
<Input
id="beat-title"
value={(nodeData.title as string) ?? ""}
onChange={(e) => updateField("title", e.target.value)}
placeholder="Beat title"
className="bg-zinc-800 border-zinc-700"
/>
</div>
<div className="space-y-2">
<Label htmlFor="beat-text">Text</Label>
<textarea
id="beat-text"
value={(nodeData.text as string) ?? ""}
onChange={(e) => updateField("text", e.target.value)}
placeholder="Describe the story beat..."
rows={4}
className="flex w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
);
case "choice": {
const options = (nodeData.options as Array<{ id: string; label: string }>) ?? [];
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="choice-question">Question</Label>
<Input
id="choice-question"
value={(nodeData.question as string) ?? ""}
onChange={(e) => updateField("question", e.target.value)}
placeholder="What decision does the player face?"
className="bg-zinc-800 border-zinc-700"
/>
</div>
<div className="space-y-2">
<Label>Options</Label>
{options.map((opt, i) => (
<div key={opt.id} className="flex items-center gap-2">
<span className="text-xs text-zinc-500 w-4">{i + 1}.</span>
<Input
value={opt.label}
onChange={(e) => {
const updated = options.map((o) =>
o.id === opt.id ? { ...o, label: e.target.value } : o,
);
updateField("options", updated);
}}
placeholder="Option label"
className="bg-zinc-800 border-zinc-700"
/>
<button
onClick={() => {
const updated = options.filter((o) => o.id !== opt.id);
updateField("options", updated);
}}
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
>
Remove
</button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const updated = [
...options,
{ id: crypto.randomUUID(), label: "" },
];
updateField("options", updated);
}}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs"
>
+ Add option
</Button>
</div>
</div>
);
}
case "outcome": {
const effects = (nodeData.effects as Array<{ type: string; value: string }>) ?? [];
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="outcome-title">Title</Label>
<Input
id="outcome-title"
value={(nodeData.title as string) ?? ""}
onChange={(e) => updateField("title", e.target.value)}
placeholder="Outcome title"
className="bg-zinc-800 border-zinc-700"
/>
</div>
<div className="space-y-2">
<Label htmlFor="outcome-text">Text</Label>
<textarea
id="outcome-text"
value={(nodeData.text as string) ?? ""}
onChange={(e) => updateField("text", e.target.value)}
placeholder="Describe the outcome..."
rows={3}
className="flex w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none"
/>
</div>
<div className="space-y-2">
<Label>Effects</Label>
{effects.map((eff, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={eff.type}
onChange={(e) => {
const updated = effects.map((o, j) =>
j === i ? { ...o, type: e.target.value } : o,
);
updateField("effects", updated);
}}
placeholder="Type (e.g. add_flag)"
className="bg-zinc-800 border-zinc-700 flex-1"
/>
<Input
value={eff.value}
onChange={(e) => {
const updated = effects.map((o, j) =>
j === i ? { ...o, value: e.target.value } : o,
);
updateField("effects", updated);
}}
placeholder="Value"
className="bg-zinc-800 border-zinc-700 flex-1"
/>
<button
onClick={() => {
const updated = effects.filter((_, j) => j !== i);
updateField("effects", updated);
}}
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
>
Remove
</button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const updated = [...effects, { type: "", value: "" }];
updateField("effects", updated);
}}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs"
>
+ Add effect
</Button>
</div>
</div>
);
}
default:
return <p className="text-sm text-zinc-500">Unknown node type.</p>;
}
};
const typeLabel =
nodeType === "narrativeBeat"
? "Beat"
: nodeType === "choice"
? "Choice"
: nodeType === "outcome"
? "Outcome"
: "Node";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-zinc-900 border-zinc-700 text-white max-w-lg">
<DialogHeader>
<DialogTitle>Edit {typeLabel}</DialogTitle>
</DialogHeader>
{renderFields()}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={handleCancel}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
Cancel
</Button>
<Button
onClick={handleSave}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,49 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { FlagIcon, PencilIcon } from "lucide-react";
export function OutcomeNode({ data }: NodeProps) {
const d = data as {
title?: string;
text?: string;
effects?: Array<{ type: string; value: string }>;
};
const effects = d.effects ?? [];
return (
<div className="bg-emerald-950/80 border border-emerald-500/50 rounded-lg shadow-md min-w-56">
<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">
<FlagIcon className="size-4 text-emerald-400 shrink-0" />
<span className="text-sm font-medium text-emerald-200 flex-1 truncate">
{d.title || "Untitled Outcome"}
</span>
<PencilIcon className="size-3 text-emerald-500/40 shrink-0" />
</div>
<div className="px-3 py-2 border-b border-emerald-500/20 text-xs text-emerald-300/80 line-clamp-2">
{d.text || "No description."}
</div>
<div className="px-3 py-2 space-y-1">
<span className="text-[10px] uppercase tracking-wider text-emerald-500/60 font-medium">
Effects
</span>
{effects.length === 0 ? (
<span className="text-xs text-emerald-400/40 italic block">None</span>
) : (
effects.map((eff, i) => (
<div
key={i}
className="flex items-center gap-1 text-xs text-emerald-300/70"
>
<span className="text-emerald-400/80 font-mono">{eff.type}</span>
<span className="text-emerald-500/40">:</span>
<span className="truncate">{eff.value}</span>
</div>
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View file

@ -1449,8 +1449,38 @@ export interface ModList {
*/
export interface NarrativeEvent {
id: number;
/**
* The title of this narrative event chain.
*/
name: string;
/**
* A brief summary of what this event is about.
*/
summary: string;
flowData:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
triggers?: {
type?: ('manual' | 'mission_complete' | 'timer' | 'condition') | null;
/**
* Optional JSON conditions for when this event can fire.
*/
condition?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
};
updatedAt: string;
createdAt: string;
}
@ -2279,6 +2309,13 @@ export interface MapsSelect<T extends boolean = true> {
export interface NarrativeEventsSelect<T extends boolean = true> {
name?: T;
summary?: T;
flowData?: T;
triggers?:
| T
| {
type?: T;
condition?: T;
};
updatedAt?: T;
createdAt?: T;
}