Compare commits
10 Commits
548f3743d1
...
a85d7cbeb3
| Author | SHA1 | Date |
|---|---|---|
|
|
a85d7cbeb3 | |
|
|
a3e1d1cea2 | |
|
|
49698dd9a9 | |
|
|
b570dca1b8 | |
|
|
68bfe88842 | |
|
|
92d892fb73 | |
|
|
b6cb0c703a | |
|
|
b4b9f8cec9 | |
|
|
30aebe4079 | |
|
|
5493adf44a |
12
prd.json
12
prd.json
|
|
@ -109,7 +109,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 6,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-056, US-065"
|
||||
},
|
||||
{
|
||||
|
|
@ -127,7 +127,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 7,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-057, US-065"
|
||||
},
|
||||
{
|
||||
|
|
@ -146,7 +146,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 8,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-057, US-065"
|
||||
},
|
||||
{
|
||||
|
|
@ -164,7 +164,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 9,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-057, US-065"
|
||||
},
|
||||
{
|
||||
|
|
@ -181,7 +181,7 @@
|
|||
"Typecheck passes"
|
||||
],
|
||||
"priority": 10,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-054, US-058, US-059"
|
||||
},
|
||||
{
|
||||
|
|
@ -199,7 +199,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 11,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-056, US-057"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
86
progress.txt
86
progress.txt
|
|
@ -28,11 +28,21 @@
|
|||
- Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons
|
||||
- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
|
||||
- Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts`
|
||||
- `EditorContext` at `src/components/editor/EditorContext.tsx` provides shared state (characters, onAddCharacter) to all custom node components via React context
|
||||
- Use `useEditorContext()` in node components to access project-level characters and variables without prop drilling through React Flow node data
|
||||
- New JSONB fields (characters, variables) must be defaulted to `[]` when reading from DB in page.tsx to handle pre-existing data
|
||||
- Reusable `Combobox` component at `src/components/editor/Combobox.tsx` - use for all character/variable dropdowns. Props: items (ComboboxItem[]), value, onChange, placeholder, onAddNew
|
||||
- `ProjectSettingsModal` at `src/components/editor/ProjectSettingsModal.tsx` manages characters/variables. Receives state + callbacks from FlowchartEditor
|
||||
- Characters and variables state is managed in `FlowchartEditorInner` with `useState` hooks, passed down to the modal
|
||||
- For settings-style modals, use `max-w-2xl h-[80vh]` with overflow-y-auto content area and fixed header/tabs
|
||||
- `EditorContext` provides both characters (onAddCharacter) and variables (onAddVariable) to node components. Use `useEditorContext()` to access them.
|
||||
- In FlowchartEditor, `handleAddVariable` adds a variable *node* to the canvas; `handleAddVariableDefinition` creates a variable *definition* in project data. Avoid naming collisions between "add node" and "add definition" callbacks.
|
||||
- Edge interactions use `onEdgeClick` on ReactFlow component. ConditionEditor opens as a modal overlay since React Flow edges don't support inline panels.
|
||||
- `Condition.value` supports `number | string | boolean` — always check variable type before rendering value inputs for edge conditions.
|
||||
- `OptionConditionEditor` at `src/components/editor/OptionConditionEditor.tsx` handles choice option conditions. Same pattern as `ConditionEditor` but with simpler props (no edgeId).
|
||||
- `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions.
|
||||
- React Compiler lint forbids `setState` in effects and reading `useRef().current` during render. Use `useState(() => computeValue())` lazy initializer pattern for one-time initialization logic.
|
||||
- For detecting legacy data shape (pre-migration), pass a flag from the server component (page.tsx) to the client component, since only the server reads raw DB data.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -97,3 +107,79 @@
|
|||
- The same form patterns from CharactersTab apply: inline form within the list for editing, appended form below the list for adding
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-059
|
||||
- What was implemented: Variable node variable dropdown using Combobox component, replacing the free-text input
|
||||
- Files changed:
|
||||
- `src/components/editor/nodes/VariableNode.tsx` - Replaced text input with Combobox for variable selection, added inline "Add new variable" form with name + type, added orange warning border for invalid references, filtered operation options (add/subtract only for numeric type)
|
||||
- `src/components/editor/EditorContext.tsx` - Extended context to include `variables: Variable[]` and `onAddVariable` callback
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `handleAddVariableDefinition` callback and passed variables + onAddVariable through EditorContext
|
||||
- **Learnings for future iterations:**
|
||||
- The existing `handleAddVariable` in FlowchartEditor adds a variable *node* to the canvas (toolbar action). The new `handleAddVariableDefinition` creates a variable *definition* in the project's data. Name carefully to avoid collisions.
|
||||
- EditorContext is the shared context for node components to access project-level characters and variables. Extend it when new entity types need to be accessible from custom node components.
|
||||
- The VariableNode follows the same pattern as DialogueNode for Combobox integration: items derived via useMemo, handleSelect sets both variableId and variableName, inline add form for quick creation, hasInvalidReference for warning state.
|
||||
- Operations filtering uses `isNumeric` flag: if no variable is selected (undefined) or type is 'numeric', all operations are shown; otherwise only 'set' is available. When selecting a non-numeric variable, operation is auto-reset to 'set' if it was 'add' or 'subtract'.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-060
|
||||
- What was implemented: Edge condition variable dropdown using Combobox component, replacing free-text input with a type-aware condition editor modal
|
||||
- Files changed:
|
||||
- `src/types/flowchart.ts` - Updated `Condition.value` type from `number` to `number | string | boolean` to support all variable types
|
||||
- `src/components/editor/ConditionEditor.tsx` - New component: modal-based condition editor with Combobox for variable selection, type-aware operator filtering, type-adaptive value inputs, inline "Add new variable" form, orange warning for invalid references, and "Remove condition" action
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `onEdgeClick` handler to open ConditionEditor, `handleConditionChange` to update edge condition data, `selectedEdgeId` state, and ConditionEditor rendering
|
||||
- **Learnings for future iterations:**
|
||||
- Edge interactions in React Flow use `onEdgeClick` prop on the ReactFlow component (not on individual edges). The handler receives `(event: React.MouseEvent, edge: Edge)`.
|
||||
- The ConditionEditor is rendered as a modal overlay (fixed z-50), not as part of the edge itself — since edges don't have built-in panel/popover support in React Flow.
|
||||
- `Condition.value` was originally typed as just `number` but needed broadening to `number | string | boolean` to support string/boolean variables in conditions. This change didn't break existing code since the VariableNode's `value` field is a separate type.
|
||||
- Operator filtering for non-numeric types: only `==` and `!=` are available for string/boolean variables. When switching from a numeric variable to a string/boolean, the operator auto-resets to `==` if it was a comparison operator.
|
||||
- Value input adapts to type: number input for numeric, text input for string, boolean dropdown for boolean.
|
||||
- The `selectedEdge` is derived via `useMemo` from `edges` state and `selectedEdgeId`, so it always reflects the latest condition data.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-061
|
||||
- What was implemented: Choice option condition variable dropdown using OptionConditionEditor component with Combobox
|
||||
- Files changed:
|
||||
- `src/types/flowchart.ts` - Added `condition?: Condition` to `ChoiceOption` type; moved `Condition` type definition before `ChoiceOption` for correct reference order
|
||||
- `src/components/editor/OptionConditionEditor.tsx` - New component: modal-based condition editor for choice options with Combobox variable selection, type-aware operators, type-adaptive value inputs, inline "Add new variable" form, orange warning for invalid references
|
||||
- `src/components/editor/nodes/ChoiceNode.tsx` - Added condition button per option (clipboard icon), condition summary text below options, OptionConditionEditor integration, EditorContext usage for variables, invalid reference detection with orange warning styling
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Extended `getVariableUsageCount` to also count variable references in choice option conditions
|
||||
- **Learnings for future iterations:**
|
||||
- The `OptionConditionEditor` follows the same pattern as `ConditionEditor` but with a simpler API: it doesn't need an edgeId since it works with a single option's condition via `onChange(condition | undefined)` callback
|
||||
- The `ChoiceOption` type in `flowchart.ts` now references `Condition`, which required reordering type definitions (Condition must be defined before ChoiceOption)
|
||||
- Each choice option shows a small clipboard icon button that turns blue when a condition is set, or orange when the referenced variable is invalid/deleted
|
||||
- A condition summary line (e.g., "if score > 10") appears below each option label when a condition is active
|
||||
- The `getVariableUsageCount` in FlowchartEditor now counts three sources: variable nodes, edge conditions, and choice option conditions
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-062
|
||||
- What was implemented: Auto-migration of existing free-text speaker/variable values to character/variable definitions on project load
|
||||
- Files changed:
|
||||
- `src/app/editor/[projectId]/page.tsx` - Added `needsMigration` flag that detects whether raw DB data has characters/variables arrays
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `computeMigration()` helper function and `needsMigration` prop; migration result initializes state directly via lazy `useState` to avoid React Compiler lint issues
|
||||
- `src/components/editor/nodes/DialogueNode.tsx` - Included pre-existing US-058 changes (speaker dropdown with Combobox) that were not previously committed
|
||||
- **Learnings for future iterations:**
|
||||
- React Compiler lint (`react-hooks/set-state-in-effect`) forbids calling `setState` synchronously within `useEffect`. For one-time initialization logic, compute the result and use it directly in state initializers instead.
|
||||
- React Compiler lint (`react-hooks/refs`) forbids reading `useRef().current` during render. Use `useState(() => ...)` lazy initializer pattern instead of `useRef` for values computed once at mount.
|
||||
- The migration detection relies on `rawData.characters` being `undefined` (old projects) vs `[]` (migrated projects). The `page.tsx` server component passes `needsMigration` flag to the client component since only the server has access to the raw DB shape.
|
||||
- `computeMigration` is a pure function called outside the component render cycle (via lazy useState). It uses `nanoid()` for IDs, so it must only be called once — lazy `useState` ensures this.
|
||||
- The toast message for migration is set as initial state, so it shows immediately on first render without needing an effect.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-063
|
||||
- What was implemented: Import characters/variables from another project via modal in project settings
|
||||
- Files changed:
|
||||
- `src/components/editor/ImportFromProjectModal.tsx` - New component: project list modal with checkbox selection for characters or variables, duplicate-by-name skipping with warnings, select all/none controls
|
||||
- `src/components/editor/ProjectSettingsModal.tsx` - Added `projectId` prop, `ImportFromProjectModal` integration, and "Import from project" buttons in both Characters and Variables tabs
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Passed `projectId` through to `ProjectSettingsModal`
|
||||
- **Learnings for future iterations:**
|
||||
- The `ImportFromProjectModal` uses `z-[60]` to layer above the `ProjectSettingsModal` (which uses `z-50`), since it's rendered as a child of that modal
|
||||
- Imported characters/variables get new IDs via `nanoid()` to avoid ID collisions between projects. The original colors, types, and initial values are preserved.
|
||||
- Duplicate detection is case-insensitive by name. Duplicates are skipped (not overwritten) with a warning message shown to the user.
|
||||
- The `LoadingSpinner` component mentioned in Codebase Patterns doesn't exist; used inline text loading indicators instead.
|
||||
- Supabase client-side fetching from `createClient()` (browser) automatically scopes by the logged-in user's RLS policies, so fetching other projects just uses `.neq('id', currentProjectId)` and RLS handles ownership filtering.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
|
|
@ -23,11 +23,15 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
|||
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
||||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable } from '@/types/flowchart'
|
||||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||
import Toast from '@/components/Toast'
|
||||
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||
|
||||
type FlowchartEditorProps = {
|
||||
projectId: string
|
||||
initialData: FlowchartData
|
||||
needsMigration?: boolean
|
||||
}
|
||||
|
||||
// Convert our FlowchartNode type to React Flow Node type
|
||||
|
|
@ -56,8 +60,148 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
|||
}))
|
||||
}
|
||||
|
||||
const RANDOM_COLORS = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
|
||||
function randomHexColor(): string {
|
||||
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
||||
}
|
||||
|
||||
// Compute auto-migration of existing free-text values to character/variable definitions
|
||||
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||
if (!shouldMigrate) {
|
||||
return {
|
||||
characters: initialData.characters,
|
||||
variables: initialData.variables,
|
||||
nodes: initialData.nodes,
|
||||
edges: initialData.edges,
|
||||
toastMessage: null as string | null,
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unique speaker names from dialogue nodes
|
||||
const speakerNames = new Set<string>()
|
||||
initialData.nodes.forEach((node) => {
|
||||
if (node.type === 'dialogue' && node.data?.speaker) {
|
||||
speakerNames.add(node.data.speaker)
|
||||
}
|
||||
})
|
||||
|
||||
// Create character definitions from unique speaker names
|
||||
const newCharacters: Character[] = []
|
||||
const speakerToCharacterId = new Map<string, string>()
|
||||
speakerNames.forEach((name) => {
|
||||
const id = nanoid()
|
||||
newCharacters.push({ id, name, color: randomHexColor() })
|
||||
speakerToCharacterId.set(name, id)
|
||||
})
|
||||
|
||||
// Collect unique variable names from variable nodes, edge conditions, and choice option conditions
|
||||
const variableNames = new Set<string>()
|
||||
initialData.nodes.forEach((node) => {
|
||||
if (node.type === 'variable' && node.data.variableName) {
|
||||
variableNames.add(node.data.variableName)
|
||||
}
|
||||
if (node.type === 'choice' && node.data.options) {
|
||||
node.data.options.forEach((opt) => {
|
||||
if (opt.condition?.variableName) {
|
||||
variableNames.add(opt.condition.variableName)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
initialData.edges.forEach((edge) => {
|
||||
if (edge.data?.condition?.variableName) {
|
||||
variableNames.add(edge.data.condition.variableName)
|
||||
}
|
||||
})
|
||||
|
||||
// Create variable definitions from unique variable names
|
||||
const newVariables: Variable[] = []
|
||||
const varNameToId = new Map<string, string>()
|
||||
variableNames.forEach((name) => {
|
||||
const id = nanoid()
|
||||
newVariables.push({ id, name, type: 'numeric', initialValue: 0 })
|
||||
varNameToId.set(name, id)
|
||||
})
|
||||
|
||||
// If nothing to migrate, return original data
|
||||
if (newCharacters.length === 0 && newVariables.length === 0) {
|
||||
return {
|
||||
characters: initialData.characters,
|
||||
variables: initialData.variables,
|
||||
nodes: initialData.nodes,
|
||||
edges: initialData.edges,
|
||||
toastMessage: null as string | null,
|
||||
}
|
||||
}
|
||||
|
||||
// Update nodes with characterId/variableId references
|
||||
const migratedNodes = initialData.nodes.map((node) => {
|
||||
if (node.type === 'dialogue' && node.data.speaker) {
|
||||
const characterId = speakerToCharacterId.get(node.data.speaker)
|
||||
if (characterId) {
|
||||
return { ...node, data: { ...node.data, characterId } }
|
||||
}
|
||||
}
|
||||
if (node.type === 'variable' && node.data.variableName) {
|
||||
const variableId = varNameToId.get(node.data.variableName)
|
||||
if (variableId) {
|
||||
return { ...node, data: { ...node.data, variableId } }
|
||||
}
|
||||
}
|
||||
if (node.type === 'choice' && node.data.options) {
|
||||
const updatedOptions = node.data.options.map((opt) => {
|
||||
if (opt.condition?.variableName) {
|
||||
const variableId = varNameToId.get(opt.condition.variableName)
|
||||
if (variableId) {
|
||||
return { ...opt, condition: { ...opt.condition, variableId } }
|
||||
}
|
||||
}
|
||||
return opt
|
||||
})
|
||||
return { ...node, data: { ...node.data, options: updatedOptions } }
|
||||
}
|
||||
return node
|
||||
}) as typeof initialData.nodes
|
||||
|
||||
// Update edges with variableId references
|
||||
const migratedEdges = initialData.edges.map((edge) => {
|
||||
if (edge.data?.condition?.variableName) {
|
||||
const variableId = varNameToId.get(edge.data.condition.variableName)
|
||||
if (variableId) {
|
||||
return {
|
||||
...edge,
|
||||
data: { ...edge.data, condition: { ...edge.data.condition, variableId } },
|
||||
}
|
||||
}
|
||||
}
|
||||
return edge
|
||||
})
|
||||
|
||||
// Build toast message
|
||||
const parts: string[] = []
|
||||
if (newCharacters.length > 0) {
|
||||
parts.push(`${newCharacters.length} character${newCharacters.length > 1 ? 's' : ''}`)
|
||||
}
|
||||
if (newVariables.length > 0) {
|
||||
parts.push(`${newVariables.length} variable${newVariables.length > 1 ? 's' : ''}`)
|
||||
}
|
||||
|
||||
return {
|
||||
characters: newCharacters,
|
||||
variables: newVariables,
|
||||
nodes: migratedNodes,
|
||||
edges: migratedEdges,
|
||||
toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`,
|
||||
}
|
||||
}
|
||||
|
||||
// Inner component that uses useReactFlow hook
|
||||
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
|
||||
// Define custom node types - memoized to prevent re-renders
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
|
|
@ -70,16 +214,46 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
|
||||
const { getViewport } = useReactFlow()
|
||||
|
||||
// Compute migrated data once on first render using a lazy state initializer
|
||||
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||
toReactFlowNodes(initialData.nodes)
|
||||
toReactFlowNodes(migratedData.nodes)
|
||||
)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||
toReactFlowEdges(initialData.edges)
|
||||
toReactFlowEdges(migratedData.edges)
|
||||
)
|
||||
|
||||
const [characters, setCharacters] = useState<Character[]>(initialData.characters)
|
||||
const [variables, setVariables] = useState<Variable[]>(initialData.variables)
|
||||
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
|
||||
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||
|
||||
const handleAddCharacter = useCallback(
|
||||
(name: string, color: string): string => {
|
||||
const id = nanoid()
|
||||
const newCharacter: Character = { id, name, color }
|
||||
setCharacters((prev) => [...prev, newCharacter])
|
||||
return id
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddVariableDefinition = useCallback(
|
||||
(name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean): string => {
|
||||
const id = nanoid()
|
||||
const newVariable: Variable = { id, name, type, initialValue }
|
||||
setVariables((prev) => [...prev, newVariable])
|
||||
return id
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const editorContextValue = useMemo(
|
||||
() => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }),
|
||||
[characters, handleAddCharacter, variables, handleAddVariableDefinition]
|
||||
)
|
||||
|
||||
const getCharacterUsageCount = useCallback(
|
||||
(characterId: string) => {
|
||||
|
|
@ -96,7 +270,15 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
const edgeCount = edges.filter(
|
||||
(e) => e.data?.condition?.variableId === variableId
|
||||
).length
|
||||
return nodeCount + edgeCount
|
||||
const choiceOptionCount = nodes.filter(
|
||||
(n) => n.type === 'choice'
|
||||
).reduce((count, n) => {
|
||||
const options = n.data?.options || []
|
||||
return count + options.filter(
|
||||
(opt: { condition?: { variableId?: string } }) => opt.condition?.variableId === variableId
|
||||
).length
|
||||
}, 0)
|
||||
return nodeCount + edgeCount + choiceOptionCount
|
||||
},
|
||||
[nodes, edges]
|
||||
)
|
||||
|
|
@ -193,45 +375,89 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
|||
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
|
||||
}, [])
|
||||
|
||||
// Handle edge click to open condition editor
|
||||
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
|
||||
setSelectedEdgeId(edge.id)
|
||||
}, [])
|
||||
|
||||
// Handle condition change from ConditionEditor
|
||||
const handleConditionChange = useCallback(
|
||||
(edgeId: string, condition: Condition | undefined) => {
|
||||
setEdges((eds) =>
|
||||
eds.map((edge) =>
|
||||
edge.id === edgeId
|
||||
? { ...edge, data: condition ? { condition } : undefined }
|
||||
: edge
|
||||
)
|
||||
)
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
// Get the selected edge's condition data
|
||||
const selectedEdge = useMemo(
|
||||
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null),
|
||||
[selectedEdgeId, edges]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Toolbar
|
||||
onAddDialogue={handleAddDialogue}
|
||||
onAddChoice={handleAddChoice}
|
||||
onAddVariable={handleAddVariable}
|
||||
onSave={handleSave}
|
||||
onExport={handleExport}
|
||||
onImport={handleImport}
|
||||
onProjectSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onConnect={onConnect}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
fitView
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{showSettings && (
|
||||
<ProjectSettingsModal
|
||||
characters={characters}
|
||||
variables={variables}
|
||||
onCharactersChange={setCharacters}
|
||||
onVariablesChange={setVariables}
|
||||
onClose={() => setShowSettings(false)}
|
||||
getCharacterUsageCount={getCharacterUsageCount}
|
||||
getVariableUsageCount={getVariableUsageCount}
|
||||
<EditorProvider value={editorContextValue}>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Toolbar
|
||||
onAddDialogue={handleAddDialogue}
|
||||
onAddChoice={handleAddChoice}
|
||||
onAddVariable={handleAddVariable}
|
||||
onSave={handleSave}
|
||||
onExport={handleExport}
|
||||
onImport={handleImport}
|
||||
onProjectSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onConnect={onConnect}
|
||||
deleteKeyCode={['Delete', 'Backspace']}
|
||||
fitView
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
{showSettings && (
|
||||
<ProjectSettingsModal
|
||||
projectId={projectId}
|
||||
characters={characters}
|
||||
variables={variables}
|
||||
onCharactersChange={setCharacters}
|
||||
onVariablesChange={setVariables}
|
||||
onClose={() => setShowSettings(false)}
|
||||
getCharacterUsageCount={getCharacterUsageCount}
|
||||
getVariableUsageCount={getVariableUsageCount}
|
||||
/>
|
||||
)}
|
||||
{selectedEdge && (
|
||||
<ConditionEditor
|
||||
edgeId={selectedEdge.id}
|
||||
condition={selectedEdge.data?.condition}
|
||||
onChange={handleConditionChange}
|
||||
onClose={() => setSelectedEdgeId(null)}
|
||||
/>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type="success"
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EditorProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ export default async function EditorPage({ params }: PageProps) {
|
|||
variables: rawData.variables || [],
|
||||
}
|
||||
|
||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
||||
// the project was created before these features existed and may need auto-migration
|
||||
const needsMigration = !rawData.characters && !rawData.variables
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
|
|
@ -70,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
|
|||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
initialData={flowchartData}
|
||||
needsMigration={needsMigration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,322 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ConditionEditorProps = {
|
||||
edgeId: string
|
||||
condition: Condition | undefined
|
||||
onChange: (edgeId: string, condition: Condition | undefined) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ConditionEditor({
|
||||
edgeId,
|
||||
condition,
|
||||
onChange,
|
||||
onClose,
|
||||
}: ConditionEditorProps) {
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||
|
||||
const variableItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
badge: v.type,
|
||||
})),
|
||||
[variables]
|
||||
)
|
||||
|
||||
const selectedVariable = useMemo(() => {
|
||||
if (!condition?.variableId) return undefined
|
||||
return variables.find((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!condition?.variableId) return false
|
||||
return !variables.some((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
// Determine operators based on variable type
|
||||
const availableOperators = useMemo(() => {
|
||||
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<=', label: '<=' },
|
||||
] as const
|
||||
}
|
||||
// string and boolean only support == and !=
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
] as const
|
||||
}, [selectedVariable])
|
||||
|
||||
const handleVariableSelect = useCallback(
|
||||
(variableId: string) => {
|
||||
const variable = variables.find((v) => v.id === variableId)
|
||||
const defaultValue = variable
|
||||
? variable.type === 'numeric'
|
||||
? 0
|
||||
: variable.type === 'boolean'
|
||||
? false
|
||||
: ''
|
||||
: 0
|
||||
// Reset operator if current one is not valid for new type
|
||||
const validOperator =
|
||||
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
||||
? '=='
|
||||
: condition?.operator || '=='
|
||||
|
||||
onChange(edgeId, {
|
||||
variableName: variable?.name || '',
|
||||
variableId,
|
||||
operator: validOperator as Condition['operator'],
|
||||
value: defaultValue,
|
||||
})
|
||||
},
|
||||
[variables, condition?.operator, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(operator: string) => {
|
||||
if (!condition) return
|
||||
onChange(edgeId, {
|
||||
...condition,
|
||||
operator: operator as Condition['operator'],
|
||||
})
|
||||
},
|
||||
[condition, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: number | string | boolean) => {
|
||||
if (!condition) return
|
||||
onChange(edgeId, {
|
||||
...condition,
|
||||
value,
|
||||
})
|
||||
},
|
||||
[condition, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(() => {
|
||||
onChange(edgeId, undefined)
|
||||
onClose()
|
||||
}, [edgeId, onChange, onClose])
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewType('numeric')
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||
onChange(edgeId, {
|
||||
variableName: newName.trim(),
|
||||
variableId: newId,
|
||||
operator: '==',
|
||||
value: defaultValue,
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newType, onAddVariable, edgeId, onChange])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
// Render value input based on variable type
|
||||
const renderValueInput = () => {
|
||||
const varType = selectedVariable?.type || 'numeric'
|
||||
|
||||
if (varType === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={String(condition?.value ?? false)}
|
||||
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (varType === 'string') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(condition?.value ?? '')}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder="Value..."
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// numeric
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={typeof condition?.value === 'number' ? condition.value : 0}
|
||||
onChange={(e) => handleValueChange(parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
Edge Condition
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Variable selector */}
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Variable
|
||||
</label>
|
||||
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
||||
<Combobox
|
||||
items={variableItems}
|
||||
value={condition?.variableId}
|
||||
onChange={handleVariableSelect}
|
||||
placeholder="Select variable..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
</div>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Variable not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline add form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New variable
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="numeric">numeric</option>
|
||||
<option value="string">string</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator and value (shown when variable is selected) */}
|
||||
{condition?.variableId && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Operator
|
||||
</label>
|
||||
<select
|
||||
value={condition.operator || '=='}
|
||||
onChange={(e) => handleOperatorChange(e.target.value)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
{availableOperators.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
{condition?.variableId ? (
|
||||
<button
|
||||
onClick={handleRemoveCondition}
|
||||
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove condition
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
|
||||
type EditorContextValue = {
|
||||
characters: Character[]
|
||||
onAddCharacter: (name: string, color: string) => string // returns new character id
|
||||
variables: Variable[]
|
||||
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextValue>({
|
||||
characters: [],
|
||||
onAddCharacter: () => '',
|
||||
variables: [],
|
||||
onAddVariable: () => '',
|
||||
})
|
||||
|
||||
export const EditorProvider = EditorContext.Provider
|
||||
|
||||
export function useEditorContext() {
|
||||
return useContext(EditorContext)
|
||||
}
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
|
||||
type ImportMode = 'characters' | 'variables'
|
||||
|
||||
type ProjectListItem = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type ImportFromProjectModalProps = {
|
||||
mode: ImportMode
|
||||
currentProjectId: string
|
||||
existingCharacters: Character[]
|
||||
existingVariables: Variable[]
|
||||
onImportCharacters: (characters: Character[]) => void
|
||||
onImportVariables: (variables: Variable[]) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ImportFromProjectModal({
|
||||
mode,
|
||||
currentProjectId,
|
||||
existingCharacters,
|
||||
existingVariables,
|
||||
onImportCharacters,
|
||||
onImportVariables,
|
||||
onClose,
|
||||
}: ImportFromProjectModalProps) {
|
||||
const [projects, setProjects] = useState<ProjectListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
||||
const [sourceCharacters, setSourceCharacters] = useState<Character[]>([])
|
||||
const [sourceVariables, setSourceVariables] = useState<Variable[]>([])
|
||||
const [loadingSource, setLoadingSource] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
|
||||
// Load user's projects on mount
|
||||
useEffect(() => {
|
||||
async function fetchProjects() {
|
||||
const supabase = createClient()
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name')
|
||||
.neq('id', currentProjectId)
|
||||
.order('name')
|
||||
|
||||
if (fetchError) {
|
||||
setError('Failed to load projects')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setProjects(data || [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchProjects()
|
||||
}, [currentProjectId])
|
||||
|
||||
// Load source project's characters/variables when a project is selected
|
||||
const handleSelectProject = async (projectId: string) => {
|
||||
setSelectedProjectId(projectId)
|
||||
setLoadingSource(true)
|
||||
setWarnings([])
|
||||
setSelectedIds(new Set())
|
||||
|
||||
const supabase = createClient()
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('projects')
|
||||
.select('flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !data) {
|
||||
setError('Failed to load project data')
|
||||
setLoadingSource(false)
|
||||
return
|
||||
}
|
||||
|
||||
const flowchartData = data.flowchart_data || {}
|
||||
const chars: Character[] = flowchartData.characters || []
|
||||
const vars: Variable[] = flowchartData.variables || []
|
||||
|
||||
setSourceCharacters(chars)
|
||||
setSourceVariables(vars)
|
||||
setLoadingSource(false)
|
||||
|
||||
// Select all by default
|
||||
const items = mode === 'characters' ? chars : vars
|
||||
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||
}
|
||||
|
||||
const handleToggleItem = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const items = mode === 'characters' ? sourceCharacters : sourceVariables
|
||||
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||
}
|
||||
|
||||
const handleSelectNone = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
const importWarnings: string[] = []
|
||||
|
||||
if (mode === 'characters') {
|
||||
const selectedCharacters = sourceCharacters.filter((c) => selectedIds.has(c.id))
|
||||
const existingNames = new Set(existingCharacters.map((c) => c.name.toLowerCase()))
|
||||
const toImport: Character[] = []
|
||||
|
||||
for (const char of selectedCharacters) {
|
||||
if (existingNames.has(char.name.toLowerCase())) {
|
||||
importWarnings.push(`Skipped "${char.name}" (already exists)`)
|
||||
} else {
|
||||
// Create new ID to avoid conflicts
|
||||
toImport.push({ ...char, id: nanoid() })
|
||||
existingNames.add(char.name.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
if (importWarnings.length > 0) {
|
||||
setWarnings(importWarnings)
|
||||
}
|
||||
|
||||
if (toImport.length > 0) {
|
||||
onImportCharacters(toImport)
|
||||
}
|
||||
|
||||
if (importWarnings.length === 0) {
|
||||
onClose()
|
||||
}
|
||||
} else {
|
||||
const selectedVariables = sourceVariables.filter((v) => selectedIds.has(v.id))
|
||||
const existingNames = new Set(existingVariables.map((v) => v.name.toLowerCase()))
|
||||
const toImport: Variable[] = []
|
||||
|
||||
for (const variable of selectedVariables) {
|
||||
if (existingNames.has(variable.name.toLowerCase())) {
|
||||
importWarnings.push(`Skipped "${variable.name}" (already exists)`)
|
||||
} else {
|
||||
// Create new ID to avoid conflicts
|
||||
toImport.push({ ...variable, id: nanoid() })
|
||||
existingNames.add(variable.name.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
if (importWarnings.length > 0) {
|
||||
setWarnings(importWarnings)
|
||||
}
|
||||
|
||||
if (toImport.length > 0) {
|
||||
onImportVariables(toImport)
|
||||
}
|
||||
|
||||
if (importWarnings.length === 0) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = mode === 'characters' ? sourceCharacters : sourceVariables
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Import {mode === 'characters' ? 'Characters' : 'Variables'} from Project
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading && (
|
||||
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Loading projects...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="py-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && !selectedProjectId && (
|
||||
<>
|
||||
{projects.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
No other projects found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="mb-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Select a project to import from:
|
||||
</p>
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => handleSelectProject(project.id)}
|
||||
className="flex w-full items-center rounded-lg border border-zinc-200 px-4 py-3 text-left text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-700/50"
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{project.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loadingSource && (
|
||||
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Loading {mode}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedProjectId && !loadingSource && (
|
||||
<>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => { setSelectedProjectId(null); setWarnings([]) }}
|
||||
className="mb-3 flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to projects
|
||||
</button>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
This project has no {mode} defined.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Select all/none controls */}
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{selectedIds.size} of {items.length} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectNone}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Select none
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Item list with checkboxes */}
|
||||
<div className="space-y-1">
|
||||
{mode === 'characters'
|
||||
? sourceCharacters.map((char) => (
|
||||
<label
|
||||
key={char.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(char.id)}
|
||||
onChange={() => handleToggleItem(char.id)}
|
||||
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
|
||||
/>
|
||||
<div
|
||||
className="h-3.5 w-3.5 rounded-full"
|
||||
style={{ backgroundColor: char.color }}
|
||||
/>
|
||||
<span className="text-sm text-zinc-900 dark:text-zinc-50">
|
||||
{char.name}
|
||||
</span>
|
||||
{char.description && (
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
- {char.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))
|
||||
: sourceVariables.map((variable) => (
|
||||
<label
|
||||
key={variable.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(variable.id)}
|
||||
onChange={() => handleToggleItem(variable.id)}
|
||||
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
|
||||
/>
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
variable.type === 'numeric'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: variable.type === 'string'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
}`}>
|
||||
{variable.type}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-900 dark:text-zinc-50">
|
||||
{variable.name}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
(initial: {String(variable.initialValue)})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
|
||||
<p className="mb-1 text-xs font-medium text-orange-700 dark:text-orange-300">
|
||||
Import warnings:
|
||||
</p>
|
||||
{warnings.map((warning, i) => (
|
||||
<p key={i} className="text-xs text-orange-600 dark:text-orange-400">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with import button */}
|
||||
{selectedProjectId && !loadingSource && items.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-3 border-t border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Import {selectedIds.size > 0 ? `(${selectedIds.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type OptionConditionEditorProps = {
|
||||
condition: Condition | undefined
|
||||
onChange: (condition: Condition | undefined) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function OptionConditionEditor({
|
||||
condition,
|
||||
onChange,
|
||||
onClose,
|
||||
}: OptionConditionEditorProps) {
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||
|
||||
const variableItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
badge: v.type,
|
||||
})),
|
||||
[variables]
|
||||
)
|
||||
|
||||
const selectedVariable = useMemo(() => {
|
||||
if (!condition?.variableId) return undefined
|
||||
return variables.find((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!condition?.variableId) return false
|
||||
return !variables.some((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
const availableOperators = useMemo(() => {
|
||||
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<=', label: '<=' },
|
||||
] as const
|
||||
}
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
] as const
|
||||
}, [selectedVariable])
|
||||
|
||||
const handleVariableSelect = useCallback(
|
||||
(variableId: string) => {
|
||||
const variable = variables.find((v) => v.id === variableId)
|
||||
const defaultValue = variable
|
||||
? variable.type === 'numeric'
|
||||
? 0
|
||||
: variable.type === 'boolean'
|
||||
? false
|
||||
: ''
|
||||
: 0
|
||||
const validOperator =
|
||||
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
||||
? '=='
|
||||
: condition?.operator || '=='
|
||||
|
||||
onChange({
|
||||
variableName: variable?.name || '',
|
||||
variableId,
|
||||
operator: validOperator as Condition['operator'],
|
||||
value: defaultValue,
|
||||
})
|
||||
},
|
||||
[variables, condition?.operator, onChange]
|
||||
)
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(operator: string) => {
|
||||
if (!condition) return
|
||||
onChange({
|
||||
...condition,
|
||||
operator: operator as Condition['operator'],
|
||||
})
|
||||
},
|
||||
[condition, onChange]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: number | string | boolean) => {
|
||||
if (!condition) return
|
||||
onChange({
|
||||
...condition,
|
||||
value,
|
||||
})
|
||||
},
|
||||
[condition, onChange]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(() => {
|
||||
onChange(undefined)
|
||||
onClose()
|
||||
}, [onChange, onClose])
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewType('numeric')
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||
onChange({
|
||||
variableName: newName.trim(),
|
||||
variableId: newId,
|
||||
operator: '==',
|
||||
value: defaultValue,
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newType, onAddVariable, onChange])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const renderValueInput = () => {
|
||||
const varType = selectedVariable?.type || 'numeric'
|
||||
|
||||
if (varType === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={String(condition?.value ?? false)}
|
||||
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (varType === 'string') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(condition?.value ?? '')}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder="Value..."
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={typeof condition?.value === 'number' ? condition.value : 0}
|
||||
onChange={(e) => handleValueChange(parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
Option Condition
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Variable selector */}
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Variable
|
||||
</label>
|
||||
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
||||
<Combobox
|
||||
items={variableItems}
|
||||
value={condition?.variableId}
|
||||
onChange={handleVariableSelect}
|
||||
placeholder="Select variable..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
</div>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Variable not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline add form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New variable
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="numeric">numeric</option>
|
||||
<option value="string">string</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator and value (shown when variable is selected) */}
|
||||
{condition?.variableId && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Operator
|
||||
</label>
|
||||
<select
|
||||
value={condition.operator || '=='}
|
||||
onChange={(e) => handleOperatorChange(e.target.value)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
{availableOperators.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
{condition?.variableId ? (
|
||||
<button
|
||||
onClick={handleRemoveCondition}
|
||||
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove condition
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,10 +3,14 @@
|
|||
import { useState } from 'react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
import ImportFromProjectModal from './ImportFromProjectModal'
|
||||
|
||||
type Tab = 'characters' | 'variables'
|
||||
|
||||
type ImportModalState = { open: boolean; mode: 'characters' | 'variables' }
|
||||
|
||||
type ProjectSettingsModalProps = {
|
||||
projectId: string
|
||||
characters: Character[]
|
||||
variables: Variable[]
|
||||
onCharactersChange: (characters: Character[]) => void
|
||||
|
|
@ -26,6 +30,7 @@ function randomHexColor(): string {
|
|||
}
|
||||
|
||||
export default function ProjectSettingsModal({
|
||||
projectId,
|
||||
characters,
|
||||
variables,
|
||||
onCharactersChange,
|
||||
|
|
@ -35,6 +40,15 @@ export default function ProjectSettingsModal({
|
|||
getVariableUsageCount,
|
||||
}: ProjectSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('characters')
|
||||
const [importModal, setImportModal] = useState<ImportModalState>({ open: false, mode: 'characters' })
|
||||
|
||||
const handleImportCharacters = (imported: Character[]) => {
|
||||
onCharactersChange([...characters, ...imported])
|
||||
}
|
||||
|
||||
const handleImportVariables = (imported: Variable[]) => {
|
||||
onVariablesChange([...variables, ...imported])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
|
|
@ -90,6 +104,7 @@ export default function ProjectSettingsModal({
|
|||
characters={characters}
|
||||
onChange={onCharactersChange}
|
||||
getUsageCount={getCharacterUsageCount}
|
||||
onImport={() => setImportModal({ open: true, mode: 'characters' })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'variables' && (
|
||||
|
|
@ -97,10 +112,23 @@ export default function ProjectSettingsModal({
|
|||
variables={variables}
|
||||
onChange={onVariablesChange}
|
||||
getUsageCount={getVariableUsageCount}
|
||||
onImport={() => setImportModal({ open: true, mode: 'variables' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importModal.open && (
|
||||
<ImportFromProjectModal
|
||||
mode={importModal.mode}
|
||||
currentProjectId={projectId}
|
||||
existingCharacters={characters}
|
||||
existingVariables={variables}
|
||||
onImportCharacters={handleImportCharacters}
|
||||
onImportVariables={handleImportVariables}
|
||||
onClose={() => setImportModal({ open: false, mode: importModal.mode })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -110,6 +138,7 @@ type CharactersTabProps = {
|
|||
characters: Character[]
|
||||
onChange: (characters: Character[]) => void
|
||||
getUsageCount: (characterId: string) => number
|
||||
onImport: () => void
|
||||
}
|
||||
|
||||
type CharacterFormData = {
|
||||
|
|
@ -118,7 +147,7 @@ type CharacterFormData = {
|
|||
description: string
|
||||
}
|
||||
|
||||
function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabProps) {
|
||||
function CharactersTab({ characters, onChange, getUsageCount, onImport }: CharactersTabProps) {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<CharacterFormData>({ name: '', color: randomHexColor(), description: '' })
|
||||
|
|
@ -207,12 +236,20 @@ function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabPro
|
|||
Define characters that can be referenced in dialogue nodes.
|
||||
</p>
|
||||
{!isAdding && !editingId && (
|
||||
<button
|
||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Add Character
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Import from project
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Add Character
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -378,6 +415,7 @@ type VariablesTabProps = {
|
|||
variables: Variable[]
|
||||
onChange: (variables: Variable[]) => void
|
||||
getUsageCount: (variableId: string) => number
|
||||
onImport: () => void
|
||||
}
|
||||
|
||||
type VariableType = 'numeric' | 'string' | 'boolean'
|
||||
|
|
@ -406,7 +444,7 @@ function parseInitialValue(type: VariableType, raw: string): number | string | b
|
|||
}
|
||||
}
|
||||
|
||||
function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps) {
|
||||
function VariablesTab({ variables, onChange, getUsageCount, onImport }: VariablesTabProps) {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<VariableFormData>({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
||||
|
|
@ -503,12 +541,20 @@ function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps)
|
|||
Define variables that can be referenced in variable nodes and edge conditions.
|
||||
</p>
|
||||
{!isAdding && !editingId && (
|
||||
<button
|
||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Import from project
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ChoiceOption = {
|
||||
id: string
|
||||
label: string
|
||||
condition?: Condition
|
||||
}
|
||||
|
||||
type ChoiceNodeData = {
|
||||
|
|
@ -19,6 +23,8 @@ const MAX_OPTIONS = 6
|
|||
|
||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables } = useEditorContext()
|
||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||
|
||||
const updatePrompt = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -54,6 +60,27 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||
[id, setNodes]
|
||||
)
|
||||
|
||||
const updateOptionCondition = useCallback(
|
||||
(optionId: string, condition: Condition | undefined) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) =>
|
||||
opt.id === optionId ? { ...opt, condition } : opt
|
||||
),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const addOption = useCallback(() => {
|
||||
if (data.options.length >= MAX_OPTIONS) return
|
||||
setNodes((nodes) =>
|
||||
|
|
@ -96,68 +123,118 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||
[id, data.options.length, setNodes]
|
||||
)
|
||||
|
||||
const editingOption = useMemo(() => {
|
||||
if (!editingConditionOptionId) return null
|
||||
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
||||
}, [editingConditionOptionId, data.options])
|
||||
|
||||
const hasInvalidConditionReference = useCallback(
|
||||
(option: ChoiceOption) => {
|
||||
if (!option.condition?.variableId) return false
|
||||
return !variables.some((v) => v.id === option.condition!.variableId)
|
||||
},
|
||||
[variables]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="input"
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
/>
|
||||
<>
|
||||
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="input"
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
/>
|
||||
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
||||
Choice
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
||||
Choice
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.prompt || ''}
|
||||
onChange={updatePrompt}
|
||||
placeholder="What do you choose?"
|
||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.options.map((option, index) => (
|
||||
<div key={option.id}>
|
||||
<div className="relative flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingConditionOptionId(option.id)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||
option.condition?.variableId
|
||||
? hasInvalidConditionReference(option)
|
||||
? 'bg-orange-100 text-orange-600 ring-1 ring-orange-500 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
title={option.condition?.variableId ? 'Edit condition' : 'Add condition'}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(option.id)}
|
||||
disabled={data.options.length <= MIN_OPTIONS}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||
title="Remove option"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`option-${index}`}
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
style={{
|
||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{option.condition?.variableId && (
|
||||
<div className={`mt-0.5 ml-1 text-[10px] ${
|
||||
hasInvalidConditionReference(option)
|
||||
? 'text-orange-500 dark:text-orange-400'
|
||||
: 'text-zinc-500 dark:text-zinc-400'
|
||||
}`}>
|
||||
if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={data.options.length >= MAX_OPTIONS}
|
||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||||
title="Add option"
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.prompt || ''}
|
||||
onChange={updatePrompt}
|
||||
placeholder="What do you choose?"
|
||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.options.map((option, index) => (
|
||||
<div key={option.id} className="relative flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(option.id)}
|
||||
disabled={data.options.length <= MIN_OPTIONS}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||
title="Remove option"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`option-${index}`}
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
style={{
|
||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={data.options.length >= MAX_OPTIONS}
|
||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||||
title="Add option"
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
</div>
|
||||
{editingOption && (
|
||||
<OptionConditionEditor
|
||||
condition={editingOption.condition}
|
||||
onChange={(condition) => updateOptionCondition(editingOption.id, condition)}
|
||||
onClose={() => setEditingConditionOptionId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,56 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
|
||||
type DialogueNodeData = {
|
||||
speaker?: string
|
||||
characterId?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const RANDOM_COLORS = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
|
||||
function randomColor(): string {
|
||||
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
||||
}
|
||||
|
||||
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { characters, onAddCharacter } = useEditorContext()
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(randomColor)
|
||||
|
||||
const characterItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
characters.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.name,
|
||||
color: c.color,
|
||||
})),
|
||||
[characters]
|
||||
)
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!data.characterId) return false
|
||||
return !characters.some((c) => c.id === data.characterId)
|
||||
}, [data.characterId, characters])
|
||||
|
||||
const updateNodeData = useCallback(
|
||||
(field: keyof DialogueNodeData, value: string) => {
|
||||
(updates: Partial<DialogueNodeData>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, [field]: value } }
|
||||
? { ...node, data: { ...node.data, ...updates } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
|
|
@ -24,22 +58,52 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
|||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleSpeakerChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
updateNodeData('speaker', e.target.value)
|
||||
const handleCharacterSelect = useCallback(
|
||||
(characterId: string) => {
|
||||
const character = characters.find((c) => c.id === characterId)
|
||||
updateNodeData({
|
||||
characterId,
|
||||
speaker: character?.name || '',
|
||||
})
|
||||
},
|
||||
[updateNodeData]
|
||||
[characters, updateNodeData]
|
||||
)
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewColor(randomColor())
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const newId = onAddCharacter(newName.trim(), newColor)
|
||||
updateNodeData({
|
||||
characterId: newId,
|
||||
speaker: newName.trim(),
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newColor, onAddCharacter, updateNodeData])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateNodeData('text', e.target.value)
|
||||
updateNodeData({ text: e.target.value })
|
||||
},
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border-2 border-blue-500 bg-blue-50 p-3 shadow-md dark:border-blue-400 dark:bg-blue-950">
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-blue-500 dark:border-blue-400'
|
||||
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
|
@ -51,13 +115,63 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
|||
Dialogue
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.speaker || ''}
|
||||
onChange={handleSpeakerChange}
|
||||
placeholder="Speaker"
|
||||
className="mb-2 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<Combobox
|
||||
items={characterItems}
|
||||
value={data.characterId}
|
||||
onChange={handleCharacterSelect}
|
||||
placeholder="Select speaker..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Character not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-2 rounded border border-blue-300 bg-white p-2 dark:border-blue-600 dark:bg-zinc-800">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New character
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={newColor}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={data.text || ''}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,52 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
|
||||
type VariableNodeData = {
|
||||
variableName: string
|
||||
variableId?: string
|
||||
operation: 'set' | 'add' | 'subtract'
|
||||
value: number
|
||||
}
|
||||
|
||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
|
||||
const updateVariableName = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||
|
||||
const variableItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
badge: v.type,
|
||||
})),
|
||||
[variables]
|
||||
)
|
||||
|
||||
const selectedVariable = useMemo(() => {
|
||||
if (!data.variableId) return undefined
|
||||
return variables.find((v) => v.id === data.variableId)
|
||||
}, [data.variableId, variables])
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!data.variableId) return false
|
||||
return !variables.some((v) => v.id === data.variableId)
|
||||
}, [data.variableId, variables])
|
||||
|
||||
const updateNodeData = useCallback(
|
||||
(updates: Partial<VariableNodeData>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, variableName: e.target.value } }
|
||||
? { ...node, data: { ...node.data, ...updates } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
|
|
@ -25,35 +54,69 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
|||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleVariableSelect = useCallback(
|
||||
(variableId: string) => {
|
||||
const variable = variables.find((v) => v.id === variableId)
|
||||
const updates: Partial<VariableNodeData> = {
|
||||
variableId,
|
||||
variableName: variable?.name || '',
|
||||
}
|
||||
// Reset operation to 'set' if current operation is not valid for the variable's type
|
||||
if (variable && variable.type !== 'numeric' && (data.operation === 'add' || data.operation === 'subtract')) {
|
||||
updates.operation = 'set'
|
||||
}
|
||||
updateNodeData(updates)
|
||||
},
|
||||
[variables, data.operation, updateNodeData]
|
||||
)
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewType('numeric')
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||
updateNodeData({
|
||||
variableId: newId,
|
||||
variableName: newName.trim(),
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newType, onAddVariable, updateNodeData])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const updateOperation = useCallback(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' })
|
||||
},
|
||||
[id, setNodes]
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
const updateValue = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value) || 0
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, value } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
updateNodeData({ value })
|
||||
},
|
||||
[id, setNodes]
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
// Filter operations based on selected variable type
|
||||
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border-2 border-orange-500 bg-orange-50 p-3 shadow-md dark:border-orange-400 dark:bg-orange-950">
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-orange-500 dark:border-orange-400'
|
||||
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
|
@ -65,13 +128,68 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
|||
Variable
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.variableName || ''}
|
||||
onChange={updateVariableName}
|
||||
placeholder="variableName"
|
||||
className="mb-2 w-full rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
<div className={`mb-2 ${hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}`}>
|
||||
<Combobox
|
||||
items={variableItems}
|
||||
value={data.variableId}
|
||||
onChange={handleVariableSelect}
|
||||
placeholder="Select variable..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Variable not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-2 rounded border border-orange-300 bg-white p-2 dark:border-orange-600 dark:bg-zinc-800">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New variable
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="numeric">numeric</option>
|
||||
<option value="string">string</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-orange-600 px-2 py-0.5 text-xs text-white hover:bg-orange-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 flex gap-2">
|
||||
<select
|
||||
|
|
@ -80,8 +198,8 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
|||
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
|
||||
>
|
||||
<option value="set">set</option>
|
||||
<option value="add">add</option>
|
||||
<option value="subtract">subtract</option>
|
||||
{isNumeric && <option value="add">add</option>}
|
||||
{isNumeric && <option value="subtract">subtract</option>}
|
||||
</select>
|
||||
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ export type Variable = {
|
|||
description?: string;
|
||||
};
|
||||
|
||||
// Condition type for conditional edges and choice options
|
||||
export type Condition = {
|
||||
variableName: string;
|
||||
variableId?: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number | string | boolean;
|
||||
};
|
||||
|
||||
// DialogueNode type: represents character speech/dialogue
|
||||
export type DialogueNodeData = {
|
||||
speaker?: string;
|
||||
|
|
@ -39,6 +47,7 @@ export type DialogueNode = {
|
|||
export type ChoiceOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
condition?: Condition;
|
||||
};
|
||||
|
||||
// ChoiceNode type: represents branching decisions
|
||||
|
|
@ -70,14 +79,6 @@ export type VariableNode = {
|
|||
// Union type for all node types
|
||||
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
|
||||
|
||||
// Condition type for conditional edges
|
||||
export type Condition = {
|
||||
variableName: string;
|
||||
variableId?: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number;
|
||||
};
|
||||
|
||||
// FlowchartEdge type: represents connections between nodes
|
||||
export type FlowchartEdge = {
|
||||
id: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue