diff --git a/AGENTS.md b/AGENTS.md index c3d67ae..e6216c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/bun.lock b/bun.lock index af3dd54..954ecca 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index efca784..b566262 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index de71ba6..9327d80 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -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, diff --git a/src/collections/world/NarrativeEvents.ts b/src/collections/world/NarrativeEvents.ts index 4baadef..28bc1ff 100644 --- a/src/collections/world/NarrativeEvents.ts +++ b/src/collections/world/NarrativeEvents.ts @@ -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.", + }, + }, + ], + }, + ], }, ], }, diff --git a/src/components/admin/narrative-flow/ChoiceNode.tsx b/src/components/admin/narrative-flow/ChoiceNode.tsx new file mode 100644 index 0000000..8db2c66 --- /dev/null +++ b/src/components/admin/narrative-flow/ChoiceNode.tsx @@ -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 ( +
+ +
+ + + {d.question || "Untitled Choice"} + + +
+
+ {options.length === 0 ? ( + No options defined. + ) : ( + options.map((opt) => ( +
+ + {opt.label} + +
+ )) + )} +
+
+ ); +} diff --git a/src/components/admin/narrative-flow/NarrativeBeatNode.tsx b/src/components/admin/narrative-flow/NarrativeBeatNode.tsx new file mode 100644 index 0000000..8b690af --- /dev/null +++ b/src/components/admin/narrative-flow/NarrativeBeatNode.tsx @@ -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 ( +
+ +
+ + + {d.title || "Untitled Beat"} + + +
+
+ {d.text || "No description."} +
+ +
+ ); +} diff --git a/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx new file mode 100644 index 0000000..18dd5d7 --- /dev/null +++ b/src/components/admin/narrative-flow/NarrativeFlowEditor.tsx @@ -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(); + + 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(null); + const [editingNodeId, setEditingNodeId] = useState(null); + + const lastSaved = useRef(""); + + 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) => { + 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 ( +
+
+ + + + + {nodes.length} node{nodes.length !== 1 ? "s" : ""}, {edges.length} edge + {edges.length !== 1 ? "s" : ""} + +
+
+ + + + + n.type === "choice" + ? "#d97706" + : n.type === "outcome" + ? "#10b981" + : "#3b82f6" + } + /> + + + {contextMenu && ( + { + if (!open) setContextMenu(null); + }} + > + + setContextMenu(null)} + className="z-50 min-w-[8rem] overflow-hidden rounded-md border border-zinc-700 bg-zinc-900 p-1 shadow-lg" + > + + + Delete{contextMenu.nodeId ? " node" : contextMenu.edgeId ? " connection" : ""} + + + + + )} + + { + if (!open) setEditingNodeId(null); + }} + onSave={handleNodeSave} + /> +
+
+ ); +} + +export default NarrativeFlowEditor; diff --git a/src/components/admin/narrative-flow/NodeEditDialog.tsx b/src/components/admin/narrative-flow/NodeEditDialog.tsx new file mode 100644 index 0000000..70e563a --- /dev/null +++ b/src/components/admin/narrative-flow/NodeEditDialog.tsx @@ -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) => void; +} + +export const NodeEditDialog: FC = ({ + node, + open, + onOpenChange, + onSave, +}) => { + const [formData, setFormData] = useState>({}); + + 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 ( +
+
+ + updateField("title", e.target.value)} + placeholder="Beat title" + className="bg-zinc-800 border-zinc-700" + /> +
+
+ +