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:
parent
6efb1e1d65
commit
103340271b
12 changed files with 1025 additions and 8 deletions
17
AGENTS.md
17
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.
|
||||
|
|
|
|||
39
bun.lock
39
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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
44
src/components/admin/narrative-flow/ChoiceNode.tsx
Normal file
44
src/components/admin/narrative-flow/ChoiceNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/admin/narrative-flow/NarrativeBeatNode.tsx
Normal file
25
src/components/admin/narrative-flow/NarrativeBeatNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src/components/admin/narrative-flow/NarrativeFlowEditor.tsx
Normal file
243
src/components/admin/narrative-flow/NarrativeFlowEditor.tsx
Normal 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;
|
||||
263
src/components/admin/narrative-flow/NodeEditDialog.tsx
Normal file
263
src/components/admin/narrative-flow/NodeEditDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
src/components/admin/narrative-flow/OutcomeNode.tsx
Normal file
49
src/components/admin/narrative-flow/OutcomeNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
src/components/ui/context-menu.tsx
Normal file
252
src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue