From 5b404cbe92666c43e55ae453e25a85c01b31e4a2 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:04:54 -0300 Subject: [PATCH 01/25] feat: [US-030] - Right-click context menu Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 151 +++++++++++++++++- src/components/editor/ContextMenu.tsx | 131 +++++++++++++++ 2 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/ContextMenu.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index a37e8fc..6a7abd7 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -15,6 +15,8 @@ import ReactFlow, { Edge, NodeTypes, MarkerType, + NodeMouseHandler, + EdgeMouseHandler, } from 'reactflow' import { nanoid } from 'nanoid' import 'reactflow/dist/style.css' @@ -22,8 +24,17 @@ import Toolbar from '@/components/editor/Toolbar' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' +import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' +type ContextMenuState = { + x: number + y: number + type: ContextMenuType + nodeId?: string + edgeId?: string +} | null + type FlowchartEditorProps = { projectId: string initialData: FlowchartData @@ -67,7 +78,9 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { [] ) - const { getViewport } = useReactFlow() + const { getViewport, screenToFlowPosition } = useReactFlow() + + const [contextMenu, setContextMenu] = useState(null) const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) @@ -168,6 +181,121 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { console.log('Deleted edges:', deletedEdges.map((e) => e.id)) }, []) + // Context menu handlers + const closeContextMenu = useCallback(() => { + setContextMenu(null) + }, []) + + // Handle right-click on canvas (pane) + const onPaneContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'canvas', + }) + }, + [] + ) + + // Handle right-click on node + const onNodeContextMenu: NodeMouseHandler = useCallback( + (event, node) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'node', + nodeId: node.id, + }) + }, + [] + ) + + // Handle right-click on edge + const onEdgeContextMenu: EdgeMouseHandler = useCallback( + (event, edge) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'edge', + edgeId: edge.id, + }) + }, + [] + ) + + // Add node at specific position (for context menu) + const handleAddNodeAtPosition = useCallback( + (type: 'dialogue' | 'choice' | 'variable') => { + if (!contextMenu) return + + // Convert screen position to flow position + const position = screenToFlowPosition({ + x: contextMenu.x, + y: contextMenu.y, + }) + + let newNode: Node + + if (type === 'dialogue') { + newNode = { + id: nanoid(), + type: 'dialogue', + position, + data: { speaker: '', text: '' }, + } + } else if (type === 'choice') { + newNode = { + id: nanoid(), + type: 'choice', + position, + data: { + prompt: '', + options: [ + { id: nanoid(), label: '' }, + { id: nanoid(), label: '' }, + ], + }, + } + } else { + newNode = { + id: nanoid(), + type: 'variable', + position, + data: { + variableName: '', + operation: 'set', + value: 0, + }, + } + } + + setNodes((nodes) => [...nodes, newNode]) + }, + [contextMenu, screenToFlowPosition, setNodes] + ) + + // Delete selected node from context menu + const handleDeleteNode = useCallback(() => { + if (!contextMenu?.nodeId) return + setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId)) + }, [contextMenu, setNodes]) + + // Delete selected edge from context menu + const handleDeleteEdge = useCallback(() => { + if (!contextMenu?.edgeId) return + setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) + }, [contextMenu, setEdges]) + + // Add condition to edge (will be implemented in US-031) + const handleAddCondition = useCallback(() => { + // TODO: Implement in US-031 - will open ConditionEditor modal + console.log('Add condition to edge:', contextMenu?.edgeId) + }, [contextMenu]) + return (
@@ -194,6 +325,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
+ + {contextMenu && ( + handleAddNodeAtPosition('dialogue')} + onAddChoice={() => handleAddNodeAtPosition('choice')} + onAddVariable={() => handleAddNodeAtPosition('variable')} + onDelete={ + contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge + } + onAddCondition={handleAddCondition} + /> + )} ) } diff --git a/src/components/editor/ContextMenu.tsx b/src/components/editor/ContextMenu.tsx new file mode 100644 index 0000000..cb9ba53 --- /dev/null +++ b/src/components/editor/ContextMenu.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useCallback, useEffect } from 'react' + +export type ContextMenuType = 'canvas' | 'node' | 'edge' + +type ContextMenuProps = { + x: number + y: number + type: ContextMenuType + onClose: () => void + onAddDialogue?: () => void + onAddChoice?: () => void + onAddVariable?: () => void + onDelete?: () => void + onAddCondition?: () => void +} + +export default function ContextMenu({ + x, + y, + type, + onClose, + onAddDialogue, + onAddChoice, + onAddVariable, + onDelete, + onAddCondition, +}: ContextMenuProps) { + // Close menu on Escape key + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + }, + [onClose] + ) + + // Close menu on click outside + const handleClickOutside = useCallback(() => { + onClose() + }, [onClose]) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('click', handleClickOutside) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('click', handleClickOutside) + } + }, [handleKeyDown, handleClickOutside]) + + const menuItemClass = + 'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer' + + return ( +
e.stopPropagation()} + > + {type === 'canvas' && ( + <> + + + + + )} + + {type === 'node' && ( + + )} + + {type === 'edge' && ( + <> + + + + )} +
+ ) +} From 0d8a4059bc18e7fc9d9c4791d3e29749501784b0 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:05:29 -0300 Subject: [PATCH 02/25] chore: mark US-030 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 29d01a7..c2f770d 100644 --- a/prd.json +++ b/prd.json @@ -533,7 +533,7 @@ "Verify in browser using dev-browser skill" ], "priority": 30, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index dab9d29..012a474 100644 --- a/progress.txt +++ b/progress.txt @@ -433,3 +433,17 @@ - onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo - Edge selection shows visual highlight via React Flow's built-in styling --- + +## 2026-01-22 - US-030 +- What was implemented: Right-click context menu for canvas, nodes, and edges +- Files changed: + - src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge) + - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions +- **Learnings for future iterations:** + - Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus + - `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position + - Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions + - Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click + - Escape key listener via `document.addEventListener('keydown', handler)` for menu close + - NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks +--- From fda09038722721c1a004c6e476a7cbb9789af50d Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:08:37 -0300 Subject: [PATCH 03/25] feat: [US-031] - Condition editor modal Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 86 ++++++++- src/components/editor/ConditionEditor.tsx | 166 ++++++++++++++++++ 2 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 src/components/editor/ConditionEditor.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 6a7abd7..3ecefad 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -25,7 +25,8 @@ import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' -import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart' +import ConditionEditor from '@/components/editor/ConditionEditor' +import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' type ContextMenuState = { x: number @@ -35,6 +36,11 @@ type ContextMenuState = { edgeId?: string } | null +type ConditionEditorState = { + edgeId: string + condition?: Condition +} | null + type FlowchartEditorProps = { projectId: string initialData: FlowchartData @@ -81,6 +87,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const { getViewport, screenToFlowPosition } = useReactFlow() const [contextMenu, setContextMenu] = useState(null) + const [conditionEditor, setConditionEditor] = useState(null) const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) @@ -290,11 +297,69 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) }, [contextMenu, setEdges]) - // Add condition to edge (will be implemented in US-031) + // Open condition editor for an edge + const openConditionEditor = useCallback( + (edgeId: string) => { + const edge = edges.find((e) => e.id === edgeId) + if (!edge) return + setConditionEditor({ + edgeId, + condition: edge.data?.condition, + }) + }, + [edges] + ) + + // Add condition to edge (opens ConditionEditor modal) const handleAddCondition = useCallback(() => { - // TODO: Implement in US-031 - will open ConditionEditor modal - console.log('Add condition to edge:', contextMenu?.edgeId) - }, [contextMenu]) + if (!contextMenu?.edgeId) return + openConditionEditor(contextMenu.edgeId) + }, [contextMenu, openConditionEditor]) + + // Handle double-click on edge to open condition editor + const onEdgeDoubleClick = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + openConditionEditor(edge.id) + }, + [openConditionEditor] + ) + + // Save condition to edge + const handleSaveCondition = useCallback( + (edgeId: string, condition: Condition) => { + setEdges((eds) => + eds.map((edge) => + edge.id === edgeId + ? { ...edge, data: { ...edge.data, condition } } + : edge + ) + ) + setConditionEditor(null) + }, + [setEdges] + ) + + // Remove condition from edge + const handleRemoveCondition = useCallback( + (edgeId: string) => { + setEdges((eds) => + eds.map((edge) => { + if (edge.id !== edgeId) return edge + // Remove condition from data + const newData = { ...edge.data } + delete newData.condition + return { ...edge, data: newData } + }) + ) + setConditionEditor(null) + }, + [setEdges] + ) + + // Close condition editor + const closeConditionEditor = useCallback(() => { + setConditionEditor(null) + }, []) return (
@@ -318,6 +383,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onPaneContextMenu={onPaneContextMenu} onNodeContextMenu={onNodeContextMenu} onEdgeContextMenu={onEdgeContextMenu} + onEdgeDoubleClick={onEdgeDoubleClick} deleteKeyCode={['Delete', 'Backspace']} fitView > @@ -341,6 +407,16 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onAddCondition={handleAddCondition} /> )} + + {conditionEditor && ( + + )}
) } diff --git a/src/components/editor/ConditionEditor.tsx b/src/components/editor/ConditionEditor.tsx new file mode 100644 index 0000000..045af92 --- /dev/null +++ b/src/components/editor/ConditionEditor.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import type { Condition } from '@/types/flowchart' + +type ConditionEditorProps = { + edgeId: string + condition?: Condition + onSave: (edgeId: string, condition: Condition) => void + onRemove: (edgeId: string) => void + onCancel: () => void +} + +const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!='] + +export default function ConditionEditor({ + edgeId, + condition, + onSave, + onRemove, + onCancel, +}: ConditionEditorProps) { + const [variableName, setVariableName] = useState(condition?.variableName ?? '') + const [operator, setOperator] = useState(condition?.operator ?? '==') + const [value, setValue] = useState(condition?.value ?? 0) + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onCancel]) + + const handleSave = useCallback(() => { + if (!variableName.trim()) return + onSave(edgeId, { + variableName: variableName.trim(), + operator, + value, + }) + }, [edgeId, variableName, operator, value, onSave]) + + const handleRemove = useCallback(() => { + onRemove(edgeId) + }, [edgeId, onRemove]) + + const hasExistingCondition = !!condition + + return ( +
+
e.stopPropagation()} + > +

+ {hasExistingCondition ? 'Edit Condition' : 'Add Condition'} +

+ +
+ {/* Variable Name Input */} +
+ + setVariableName(e.target.value)} + placeholder="e.g., score, health, affection" + autoFocus + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ + {/* Operator Dropdown */} +
+ + +
+ + {/* Value Number Input */} +
+ + setValue(parseFloat(e.target.value) || 0)} + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100" + /> +
+ + {/* Preview */} + {variableName.trim() && ( +
+ + Condition: {variableName.trim()} {operator} {value} + +
+ )} +
+ + {/* Action Buttons */} +
+
+ {hasExistingCondition && ( + + )} +
+
+ + +
+
+
+
+ ) +} From e686719f29e8952c6a2e3b18d2bcd88e20b4a448 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:09:04 -0300 Subject: [PATCH 04/25] chore: mark US-031 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index c2f770d..cae515f 100644 --- a/prd.json +++ b/prd.json @@ -552,7 +552,7 @@ "Verify in browser using dev-browser skill" ], "priority": 31, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index 012a474..963615c 100644 --- a/progress.txt +++ b/progress.txt @@ -447,3 +447,17 @@ - Escape key listener via `document.addEventListener('keydown', handler)` for menu close - NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks --- + +## 2026-01-22 - US-031 +- What was implemented: Condition editor modal for adding/editing/removing conditions on edges +- Files changed: + - src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value + - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers +- **Learnings for future iterations:** + - Use `onEdgeDoubleClick` React Flow callback for double-click on edges + - Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`) + - Use `edge.data.condition` to access condition object on edges + - When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables + - Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!=' + - Preview condition in modal using template string: `${variableName} ${operator} ${value}` +--- From c431b212ac0bc8923aee1fd0d8a0c345895bdac3 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:11:28 -0300 Subject: [PATCH 05/25] feat: [US-032] - Display conditions on edges Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 15 +++- .../editor/edges/ConditionalEdge.tsx | 71 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/edges/ConditionalEdge.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 3ecefad..0df0156 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -14,6 +14,7 @@ import ReactFlow, { Node, Edge, NodeTypes, + EdgeTypes, MarkerType, NodeMouseHandler, EdgeMouseHandler, @@ -24,6 +25,7 @@ import Toolbar from '@/components/editor/Toolbar' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' +import ConditionalEdge from '@/components/editor/edges/ConditionalEdge' import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import ConditionEditor from '@/components/editor/ConditionEditor' import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' @@ -65,7 +67,7 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { target: edge.target, targetHandle: edge.targetHandle, data: edge.data, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -84,6 +86,14 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { [] ) + // Define custom edge types - memoized to prevent re-renders + const edgeTypes: EdgeTypes = useMemo( + () => ({ + conditional: ConditionalEdge, + }), + [] + ) + const { getViewport, screenToFlowPosition } = useReactFlow() const [contextMenu, setContextMenu] = useState(null) @@ -105,7 +115,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { target: params.target, sourceHandle: params.sourceHandle, targetHandle: params.targetHandle, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -376,6 +386,7 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { nodes={nodes} edges={edges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} diff --git a/src/components/editor/edges/ConditionalEdge.tsx b/src/components/editor/edges/ConditionalEdge.tsx new file mode 100644 index 0000000..c49a99d --- /dev/null +++ b/src/components/editor/edges/ConditionalEdge.tsx @@ -0,0 +1,71 @@ +'use client' + +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getSmoothStepPath, +} from 'reactflow' +import type { Condition } from '@/types/flowchart' + +type ConditionalEdgeData = { + condition?: Condition +} + +export default function ConditionalEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd, + selected, +}: EdgeProps) { + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }) + + const hasCondition = !!data?.condition + + // Format condition as readable label + const conditionLabel = hasCondition + ? `${data.condition!.variableName} ${data.condition!.operator} ${data.condition!.value}` + : null + + return ( + <> + + {conditionLabel && ( + +
+ {conditionLabel} +
+
+ )} + + ) +} From c3975dd91a4b4c605e1cc21866298c2776e4940b Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:11:53 -0300 Subject: [PATCH 06/25] chore: mark US-032 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index cae515f..6b6440c 100644 --- a/prd.json +++ b/prd.json @@ -568,7 +568,7 @@ "Verify in browser using dev-browser skill" ], "priority": 32, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index 963615c..f09ade7 100644 --- a/progress.txt +++ b/progress.txt @@ -461,3 +461,18 @@ - Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!=' - Preview condition in modal using template string: `${variableName} ${operator} ${value}` --- + +## 2026-01-22 - US-032 +- What was implemented: Display conditions on edges with dashed styling and labels +- Files changed: + - src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display + - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition +- **Learnings for future iterations:** + - Custom React Flow edges use EdgeProps typing where T is the data shape + - Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels + - `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels + - Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow + - Style edges with conditions using strokeDasharray: '5 5' for dashed lines + - Custom edges go in `src/components/editor/edges/` directory + - Use amber color scheme for conditional edges to distinguish from regular edges +--- From f6ab24c5b35f6bd436cde068288f2d90cc5f0b92 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:14:49 -0300 Subject: [PATCH 07/25] feat: [US-033] - Auto-save to LocalStorage Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 162 +++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 0df0156..3efa2c1 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -30,6 +30,12 @@ import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import ConditionEditor from '@/components/editor/ConditionEditor' import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' +// LocalStorage key prefix for draft saves +const DRAFT_KEY_PREFIX = 'vnwrite-draft-' + +// Debounce delay in ms +const AUTOSAVE_DEBOUNCE_MS = 1000 + type ContextMenuState = { x: number y: number @@ -74,8 +80,70 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { })) } +// Convert React Flow Node type back to our FlowchartNode type +function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] { + return nodes.map((node) => ({ + id: node.id, + type: node.type as 'dialogue' | 'choice' | 'variable', + position: node.position, + data: node.data, + })) as FlowchartNode[] +} + +// Convert React Flow Edge type back to our FlowchartEdge type +function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] { + return edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle ?? undefined, + target: edge.target, + targetHandle: edge.targetHandle ?? undefined, + data: edge.data, + })) +} + +// Get LocalStorage key for a project +function getDraftKey(projectId: string): string { + return `${DRAFT_KEY_PREFIX}${projectId}` +} + +// Save draft to LocalStorage +function saveDraft(projectId: string, data: FlowchartData): void { + try { + localStorage.setItem(getDraftKey(projectId), JSON.stringify(data)) + } catch (error) { + console.error('Failed to save draft to LocalStorage:', error) + } +} + +// Load draft from LocalStorage +function loadDraft(projectId: string): FlowchartData | null { + try { + const draft = localStorage.getItem(getDraftKey(projectId)) + if (!draft) return null + return JSON.parse(draft) as FlowchartData + } catch (error) { + console.error('Failed to load draft from LocalStorage:', error) + return null + } +} + +// Clear draft from LocalStorage +export function clearDraft(projectId: string): void { + try { + localStorage.removeItem(getDraftKey(projectId)) + } catch (error) { + console.error('Failed to clear draft from LocalStorage:', error) + } +} + +// Compare two FlowchartData objects for equality +function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + // Inner component that uses useReactFlow hook -function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { +function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -99,6 +167,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const [contextMenu, setContextMenu] = useState(null) const [conditionEditor, setConditionEditor] = useState(null) + // Check for saved draft on initial render (lazy initialization) + const [draftState, setDraftState] = useState<{ + showPrompt: boolean + savedDraft: FlowchartData | null + }>(() => { + // This runs only once on initial render (client-side) + if (typeof window === 'undefined') { + return { showPrompt: false, savedDraft: null } + } + const draft = loadDraft(projectId) + if (draft && !flowchartDataEquals(draft, initialData)) { + return { showPrompt: true, savedDraft: draft } + } + return { showPrompt: false, savedDraft: null } + }) + const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) ) @@ -106,6 +190,51 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { toReactFlowEdges(initialData.edges) ) + // Track debounce timer + const saveTimerRef = useRef(null) + + // Debounced auto-save to LocalStorage + useEffect(() => { + // Don't save while draft prompt is showing + if (draftState.showPrompt) return + + // Clear existing timer + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + + // Set new timer + saveTimerRef.current = setTimeout(() => { + const currentData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + saveDraft(projectId, currentData) + }, AUTOSAVE_DEBOUNCE_MS) + + // Cleanup on unmount + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + } + }, [nodes, edges, projectId, draftState.showPrompt]) + + // Handle restoring draft + const handleRestoreDraft = useCallback(() => { + if (draftState.savedDraft) { + setNodes(toReactFlowNodes(draftState.savedDraft.nodes)) + setEdges(toReactFlowEdges(draftState.savedDraft.edges)) + } + setDraftState({ showPrompt: false, savedDraft: null }) + }, [draftState.savedDraft, setNodes, setEdges]) + + // Handle discarding draft + const handleDiscardDraft = useCallback(() => { + clearDraft(projectId) + setDraftState({ showPrompt: false, savedDraft: null }) + }, [projectId]) + const onConnect = useCallback( (params: Connection) => { if (!params.source || !params.target) return @@ -428,6 +557,35 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onCancel={closeConditionEditor} /> )} + + {/* Draft restoration prompt */} + {draftState.showPrompt && ( +
+
+

+ Unsaved Draft Found +

+

+ A local draft was found that differs from the saved version. Would + you like to restore it or discard it? +

+
+ + +
+
+
+ )} ) } From e8dbd00d4cf972885f099e65d38e553ca9b149f6 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:15:21 -0300 Subject: [PATCH 08/25] chore: mark US-033 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 6b6440c..5c0649f 100644 --- a/prd.json +++ b/prd.json @@ -585,7 +585,7 @@ "Verify in browser using dev-browser skill" ], "priority": 33, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index f09ade7..6bb111b 100644 --- a/progress.txt +++ b/progress.txt @@ -476,3 +476,18 @@ - Custom edges go in `src/components/editor/edges/` directory - Use amber color scheme for conditional edges to distinguish from regular edges --- + +## 2026-01-22 - US-033 +- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt +- Files changed: + - src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI +- **Learnings for future iterations:** + - Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning + - LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts + - Debounce saves with 1 second delay using useRef for timer tracking + - Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges) + - React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`) + - Check `typeof window === 'undefined'` in lazy initializer for SSR safety + - clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save + - JSON.stringify comparison works for flowchart data equality check +--- From 01f5428dd9d1d1c9fccc10c83957fc8932b1b3a9 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:17:33 -0300 Subject: [PATCH 09/25] feat: [US-034] - Save project to database Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 54 +++++++++++++++++-- src/components/editor/Toolbar.tsx | 29 +++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 3efa2c1..6462ae4 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -22,6 +22,8 @@ import ReactFlow, { import { nanoid } from 'nanoid' import 'reactflow/dist/style.css' import Toolbar from '@/components/editor/Toolbar' +import Toast from '@/components/Toast' +import { createClient } from '@/lib/supabase/client' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' @@ -166,6 +168,8 @@ function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) const [contextMenu, setContextMenu] = useState(null) const [conditionEditor, setConditionEditor] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null) // Check for saved draft on initial render (lazy initialization) const [draftState, setDraftState] = useState<{ @@ -308,9 +312,43 @@ function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) setNodes((nodes) => [...nodes, newNode]) }, [getViewportCenter, setNodes]) - const handleSave = useCallback(() => { - // TODO: Implement in US-034 - }, []) + const handleSave = useCallback(async () => { + if (isSaving) return + + setIsSaving(true) + + try { + const supabase = createClient() + + // Convert React Flow state to FlowchartData + const flowchartData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + + const { error } = await supabase + .from('projects') + .update({ + flowchart_data: flowchartData, + updated_at: new Date().toISOString(), + }) + .eq('id', projectId) + + if (error) { + throw error + } + + // Clear LocalStorage draft after successful save + clearDraft(projectId) + + setToast({ message: 'Project saved successfully', type: 'success' }) + } catch (error) { + console.error('Failed to save project:', error) + setToast({ message: 'Failed to save project. Please try again.', type: 'error' }) + } finally { + setIsSaving(false) + } + }, [isSaving, nodes, edges, projectId]) const handleExport = useCallback(() => { // TODO: Implement in US-035 @@ -509,6 +547,7 @@ function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) onSave={handleSave} onExport={handleExport} onImport={handleImport} + isSaving={isSaving} />
)} + + {/* Toast notification */} + {toast && ( + setToast(null)} + /> + )} ) } diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 08f0243..0b26706 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -7,6 +7,7 @@ type ToolbarProps = { onSave: () => void onExport: () => void onImport: () => void + isSaving?: boolean } export default function Toolbar({ @@ -16,6 +17,7 @@ export default function Toolbar({ onSave, onExport, onImport, + isSaving = false, }: ToolbarProps) { return (
@@ -46,9 +48,32 @@ export default function Toolbar({
)} + {/* Import confirmation dialog */} + {importConfirmDialog && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes. Importing a new file will discard your + current work. Are you sure you want to continue? +

+
+ + +
+
+
+ )} + + {/* Hidden file input for import */} + + {/* Toast notification */} {toast && ( Date: Thu, 22 Jan 2026 18:26:25 -0300 Subject: [PATCH 13/25] feat: [US-037] - Export to Ren'Py JSON format Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 273 ++++++++++++++++++ src/components/editor/Toolbar.tsx | 8 + 2 files changed, 281 insertions(+) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 0c5b024..47f1ccd 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -154,6 +154,239 @@ function isValidFlowchartData(data: unknown): data is FlowchartData { return true } +// Ren'Py export types +type RenpyDialogueNode = { + type: 'dialogue' + speaker: string + text: string + next?: string + condition?: Condition +} + +type RenpyMenuChoice = { + label: string + next?: string + condition?: Condition +} + +type RenpyMenuNode = { + type: 'menu' + prompt: string + choices: RenpyMenuChoice[] +} + +type RenpyVariableNode = { + type: 'variable' + name: string + operation: 'set' | 'add' | 'subtract' + value: number + next?: string + condition?: Condition +} + +type RenpyNode = RenpyDialogueNode | RenpyMenuNode | RenpyVariableNode + +type RenpyExport = { + projectName: string + exportedAt: string + sections: Record +} + +// Find the first node (node with no incoming edges) +function findFirstNode(nodes: FlowchartNode[], edges: FlowchartEdge[]): FlowchartNode | null { + const targetIds = new Set(edges.map((e) => e.target)) + const startNodes = nodes.filter((n) => !targetIds.has(n.id)) + // Return the first start node, or the first node if all have incoming edges + return startNodes[0] || nodes[0] || null +} + +// Get outgoing edge from a node (for dialogue and variable nodes) +function getOutgoingEdge(nodeId: string, edges: FlowchartEdge[], sourceHandle?: string): FlowchartEdge | undefined { + return edges.find((e) => e.source === nodeId && (sourceHandle === undefined || e.sourceHandle === sourceHandle)) +} + +// Get all outgoing edges from a node (for choice nodes) +function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] { + return edges.filter((e) => e.source === nodeId) +} + +// Convert flowchart to Ren'Py format using graph traversal +function convertToRenpyFormat( + nodes: FlowchartNode[], + edges: FlowchartEdge[], + projectName: string +): RenpyExport { + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const visited = new Set() + const sections: Record = {} + let currentSectionName = 'start' + let currentSection: RenpyNode[] = [] + + // Helper to get or create a label for a node + const nodeLabels = new Map() + let labelCounter = 0 + + function getNodeLabel(nodeId: string): string { + if (!nodeLabels.has(nodeId)) { + const node = nodeMap.get(nodeId) + if (node?.type === 'dialogue' && node.data.speaker) { + // Use speaker name as part of label if available + nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`) + } else { + nodeLabels.set(nodeId, `section_${labelCounter++}`) + } + } + return nodeLabels.get(nodeId)! + } + + // Process a node and its successors + function processNode(nodeId: string): void { + if (visited.has(nodeId)) return + visited.add(nodeId) + + const node = nodeMap.get(nodeId) + if (!node) return + + if (node.type === 'dialogue') { + const outgoingEdge = getOutgoingEdge(nodeId, edges) + const renpyNode: RenpyDialogueNode = { + type: 'dialogue', + speaker: node.data.speaker || '', + text: node.data.text, + } + + if (outgoingEdge) { + // Check if target node is already visited (creates a jump) + if (visited.has(outgoingEdge.target)) { + renpyNode.next = getNodeLabel(outgoingEdge.target) + } else { + renpyNode.next = outgoingEdge.target + } + if (outgoingEdge.data?.condition) { + renpyNode.condition = outgoingEdge.data.condition + } + } + + currentSection.push(renpyNode) + + // Process next node if not visited + if (outgoingEdge && !visited.has(outgoingEdge.target)) { + processNode(outgoingEdge.target) + } + } else if (node.type === 'choice') { + const outgoingEdges = getOutgoingEdges(nodeId, edges) + + // Map options to their corresponding edges + const choices: RenpyMenuChoice[] = node.data.options.map((option, index) => { + // Find edge for this option handle + const optionEdge = outgoingEdges.find((e) => e.sourceHandle === `option-${index}`) + const choice: RenpyMenuChoice = { + label: option.label || `Option ${index + 1}`, + } + + if (optionEdge) { + // If target is visited, use label; otherwise use target id + if (visited.has(optionEdge.target)) { + choice.next = getNodeLabel(optionEdge.target) + } else { + choice.next = optionEdge.target + } + if (optionEdge.data?.condition) { + choice.condition = optionEdge.data.condition + } + } + + return choice + }) + + const renpyNode: RenpyMenuNode = { + type: 'menu', + prompt: node.data.prompt || '', + choices, + } + + currentSection.push(renpyNode) + + // Save current section before processing branches + sections[currentSectionName] = currentSection + + // Process each branch in a new section + for (const choice of choices) { + if (choice.next && !visited.has(choice.next)) { + const targetNode = nodeMap.get(choice.next) + if (targetNode) { + // Start new section for this branch + currentSectionName = getNodeLabel(choice.next) + currentSection = [] + processNode(choice.next) + if (currentSection.length > 0) { + sections[currentSectionName] = currentSection + } + } + } + } + } else if (node.type === 'variable') { + const outgoingEdge = getOutgoingEdge(nodeId, edges) + const renpyNode: RenpyVariableNode = { + type: 'variable', + name: node.data.variableName, + operation: node.data.operation, + value: node.data.value, + } + + if (outgoingEdge) { + if (visited.has(outgoingEdge.target)) { + renpyNode.next = getNodeLabel(outgoingEdge.target) + } else { + renpyNode.next = outgoingEdge.target + } + if (outgoingEdge.data?.condition) { + renpyNode.condition = outgoingEdge.data.condition + } + } + + currentSection.push(renpyNode) + + if (outgoingEdge && !visited.has(outgoingEdge.target)) { + processNode(outgoingEdge.target) + } + } + } + + // Find and process starting from the first node + const firstNode = findFirstNode(nodes, edges) + if (firstNode) { + processNode(firstNode.id) + // Save the final section if it has content + if (currentSection.length > 0 && !sections[currentSectionName]) { + sections[currentSectionName] = currentSection + } + } + + // Replace node IDs in next fields with proper labels + for (const sectionNodes of Object.values(sections)) { + for (const renpyNode of sectionNodes) { + if (renpyNode.type === 'dialogue' || renpyNode.type === 'variable') { + if (renpyNode.next && nodeLabels.has(renpyNode.next)) { + renpyNode.next = nodeLabels.get(renpyNode.next) + } + } else if (renpyNode.type === 'menu') { + for (const choice of renpyNode.choices) { + if (choice.next && nodeLabels.has(choice.next)) { + choice.next = nodeLabels.get(choice.next) + } + } + } + } + } + + return { + projectName, + exportedAt: new Date().toISOString(), + sections, + } +} + // Inner component that uses useReactFlow hook function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders @@ -394,6 +627,45 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart URL.revokeObjectURL(url) }, [nodes, edges, projectName]) + const handleExportRenpy = useCallback(() => { + // Convert React Flow state to our flowchart types + const flowchartNodes = fromReactFlowNodes(nodes) + const flowchartEdges = fromReactFlowEdges(edges) + + // Convert to Ren'Py format + const renpyExport = convertToRenpyFormat(flowchartNodes, flowchartEdges, projectName) + + // Create pretty-printed JSON + const jsonContent = JSON.stringify(renpyExport, null, 2) + + // Verify JSON is valid + try { + JSON.parse(jsonContent) + } catch { + setToast({ message: 'Failed to generate valid JSON', type: 'error' }) + return + } + + // Create blob with JSON content + const blob = new Blob([jsonContent], { type: 'application/json' }) + + // Create download URL + const url = URL.createObjectURL(blob) + + // Create temporary link element and trigger download + const link = document.createElement('a') + link.href = url + link.download = `${projectName}-renpy.json` + document.body.appendChild(link) + link.click() + + // Cleanup + document.body.removeChild(link) + URL.revokeObjectURL(url) + + setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' }) + }, [nodes, edges, projectName]) + // Check if current flowchart has unsaved changes const hasUnsavedChanges = useCallback(() => { const currentData: FlowchartData = { @@ -668,6 +940,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart onAddVariable={handleAddVariable} onSave={handleSave} onExport={handleExport} + onExportRenpy={handleExportRenpy} onImport={handleImport} isSaving={isSaving} /> diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 0b26706..c017111 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -6,6 +6,7 @@ type ToolbarProps = { onAddVariable: () => void onSave: () => void onExport: () => void + onExportRenpy: () => void onImport: () => void isSaving?: boolean } @@ -16,6 +17,7 @@ export default function Toolbar({ onAddVariable, onSave, onExport, + onExportRenpy, onImport, isSaving = false, }: ToolbarProps) { @@ -81,6 +83,12 @@ export default function Toolbar({ > Export + +

+ {projectName} +

+ {isDirty && ( + + (unsaved changes) + + )} +
+ + )} + {/* Navigation warning dialog */} + {showNavigationWarning && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes that will be lost if you leave this page. + Are you sure you want to leave? +

+
+ + +
+
+
+ )} + {/* Hidden file input for import */} -
-
- - - - - -

- {project.name} -

-
-
- -
- -
- + ) } From f1e92ba1a085dd3830810efc544d6c3d9cd45266 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:04:02 -0300 Subject: [PATCH 16/25] chore: mark US-038 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 45673e1..d8b6c36 100644 --- a/prd.json +++ b/prd.json @@ -675,7 +675,7 @@ "Verify in browser using dev-browser skill" ], "priority": 38, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index 769e1e2..d14762f 100644 --- a/progress.txt +++ b/progress.txt @@ -552,3 +552,18 @@ - Validate JSON output with JSON.parse before download to ensure validity - Use purple color scheme for Ren'Py-specific button to distinguish from generic export --- + +## 2026-01-22 - US-038 +- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal +- Files changed: + - src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component + - src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access) +- **Learnings for future iterations:** + - Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef + - lastSavedDataRef is a useRef initialized with initialData and updated after successful save + - Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers + - Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state + - Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal + - Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action + - "(unsaved changes)" indicator shown next to project name when isDirty is true +--- From b9d778b379c804ffa9ca407fdf0edfe2e9668061 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:06:43 -0300 Subject: [PATCH 17/25] feat: [US-039] - Loading and error states Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 15 ++++- src/app/editor/[projectId]/loading.tsx | 28 +--------- src/app/editor/[projectId]/page.tsx | 56 ++++++++++++++++++- src/components/LoadingSpinner.tsx | 40 +++++++++++++ src/components/Toast.tsx | 19 ++++++- 5 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 src/components/LoadingSpinner.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 3cd6759..60bb57d 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -413,7 +413,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart const [contextMenu, setContextMenu] = useState(null) const [conditionEditor, setConditionEditor] = useState(null) const [isSaving, setIsSaving] = useState(false) - const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null) + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; action?: { label: string; onClick: () => void } } | null>(null) const [importConfirmDialog, setImportConfirmDialog] = useState<{ pendingData: FlowchartData } | null>(null) @@ -425,6 +425,9 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart // Ref for hidden file input const fileInputRef = useRef(null) + // Ref for save function to enable retry without circular dependency + const handleSaveRef = useRef<() => void>(() => {}) + // Check for saved draft on initial render (lazy initialization) const [draftState, setDraftState] = useState<{ showPrompt: boolean @@ -628,12 +631,19 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart setToast({ message: 'Project saved successfully', type: 'success' }) } catch (error) { console.error('Failed to save project:', error) - setToast({ message: 'Failed to save project. Please try again.', type: 'error' }) + setToast({ + message: 'Failed to save project.', + type: 'error', + action: { label: 'Retry', onClick: () => { setToast(null); handleSaveRef.current() } }, + }) } finally { setIsSaving(false) } }, [isSaving, nodes, edges, projectId]) + // Keep ref updated with latest handleSave + handleSaveRef.current = handleSave + const handleExport = useCallback(() => { // Convert React Flow state to FlowchartData const flowchartData: FlowchartData = { @@ -1184,6 +1194,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart message={toast.message} type={toast.type} onClose={() => setToast(null)} + action={toast.action} /> )} diff --git a/src/app/editor/[projectId]/loading.tsx b/src/app/editor/[projectId]/loading.tsx index 9ec9c55..231ffce 100644 --- a/src/app/editor/[projectId]/loading.tsx +++ b/src/app/editor/[projectId]/loading.tsx @@ -1,3 +1,5 @@ +import LoadingSpinner from '@/components/LoadingSpinner' + export default function EditorLoading() { return (
@@ -9,31 +11,7 @@ export default function EditorLoading() {
-
- - - - -

- Loading editor... -

-
+
) diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 95bb0aa..59ed19d 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -1,5 +1,5 @@ import { createClient } from '@/lib/supabase/server' -import { notFound } from 'next/navigation' +import Link from 'next/link' import FlowchartEditor from './FlowchartEditor' import type { FlowchartData } from '@/types/flowchart' @@ -27,7 +27,59 @@ export default async function EditorPage({ params }: PageProps) { .single() if (error || !project) { - notFound() + return ( +
+
+ + + + + +
+
+
+ + + +

+ Project Not Found +

+

+ The project you're looking for doesn't exist or you don't have access to it. +

+ + Back to Dashboard + +
+
+
+ ) } const flowchartData = (project.flowchart_data || { diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..bc2e451 --- /dev/null +++ b/src/components/LoadingSpinner.tsx @@ -0,0 +1,40 @@ +type LoadingSpinnerProps = { + size?: 'sm' | 'md' | 'lg' + message?: string +} + +const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-8 w-8', + lg: 'h-12 w-12', +} + +export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) { + return ( +
+ + + + + {message && ( +

{message}

+ )} +
+ ) +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 18ef18a..52b9687 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -6,16 +6,23 @@ interface ToastProps { message: string type: 'success' | 'error' onClose: () => void + action?: { + label: string + onClick: () => void + } } -export default function Toast({ message, type, onClose }: ToastProps) { +export default function Toast({ message, type, onClose, action }: ToastProps) { useEffect(() => { + // Don't auto-dismiss if there's an action button + if (action) return + const timer = setTimeout(() => { onClose() }, 3000) return () => clearTimeout(timer) - }, [onClose]) + }, [onClose, action]) const bgColor = type === 'success' @@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) { > {icon} {message} + {action && ( + + )} + + + ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6a67346..f9033de 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,130 +1,23 @@ -'use client' - -import { useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Link from 'next/link' -import { createClient } from '@/lib/supabase/client' +import { Suspense } from 'react' +import LoginForm from './LoginForm' export default function LoginPage() { - const router = useRouter() - const searchParams = useSearchParams() - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - // Check for success message from password reset - const message = searchParams.get('message') - const successMessage = message === 'password_reset_success' - ? 'Your password has been reset successfully. Please sign in with your new password.' - : null - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError(null) - setLoading(true) - - const supabase = createClient() - - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }) - - if (error) { - setError(error.message) - setLoading(false) - return - } - - router.push('/dashboard') - } - return (
-
-
-

- WebVNWrite -

-

- Sign in to your account -

-
- -
- {successMessage && ( -
-

{successMessage}

-
- )} - - {error && ( -
-

{error}

-
- )} - -
-
- - setEmail(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="you@example.com" - /> -
- -
- - setPassword(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="••••••••" - /> -
+ +

+ WebVNWrite +

+

+ Loading... +

- -
- - Forgot your password? - -
- - -
-
+ } + > + +
) } diff --git a/src/app/signup/SignupForm.tsx b/src/app/signup/SignupForm.tsx new file mode 100644 index 0000000..e839439 --- /dev/null +++ b/src/app/signup/SignupForm.tsx @@ -0,0 +1,236 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' + +export default function SignupForm() { + const router = useRouter() + const searchParams = useSearchParams() + // Pre-fill email if provided in URL (from invite link) + const [email, setEmail] = useState(searchParams.get('email') ?? '') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + // Handle invite/signup token from URL hash + // Supabase adds tokens to the URL hash after redirect + const handleTokenFromUrl = async () => { + const hash = window.location.hash + if (hash) { + const params = new URLSearchParams(hash.substring(1)) + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + const type = params.get('type') + + if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) { + const supabase = createClient() + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }) + + if (error) { + setError('Invalid or expired invite link. Please request a new invitation.') + return + } + + // Get the user's email from the session + const { data: { user } } = await supabase.auth.getUser() + if (user?.email) { + setEmail(user.email) + } + } + } + } + + handleTokenFromUrl() + }, [searchParams]) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + // Validate passwords match + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + // Validate password length + if (password.length < 6) { + setError('Password must be at least 6 characters') + return + } + + setLoading(true) + + const supabase = createClient() + + // Check if user already has a session (from invite link) + const { data: { session } } = await supabase.auth.getSession() + + if (session) { + // User was invited and has a session - update their password + const { error: updateError } = await supabase.auth.updateUser({ + password, + }) + + if (updateError) { + setError(updateError.message) + setLoading(false) + return + } + + // Create profile record + const { error: profileError } = await supabase.from('profiles').upsert({ + id: session.user.id, + email: session.user.email, + display_name: session.user.email?.split('@')[0] || 'User', + is_admin: false, + }) + + if (profileError) { + setError(profileError.message) + setLoading(false) + return + } + + router.push('/dashboard') + } else { + // Regular signup flow (if allowed) + const { data, error } = await supabase.auth.signUp({ + email, + password, + }) + + if (error) { + setError(error.message) + setLoading(false) + return + } + + if (data.user) { + // Create profile record + const { error: profileError } = await supabase.from('profiles').upsert({ + id: data.user.id, + email: data.user.email, + display_name: email.split('@')[0] || 'User', + is_admin: false, + }) + + if (profileError) { + setError(profileError.message) + setLoading(false) + return + } + + router.push('/dashboard') + } + } + } + + return ( +
+
+

+ WebVNWrite +

+

+ Complete your account setup +

+
+ +
+ {error && ( +
+

{error}

+
+ )} + +
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+
+ + + +

+ Already have an account?{' '} + + Sign in + +

+
+
+ ) +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index fb1a733..de79472 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,238 +1,23 @@ -'use client' - -import { useState, useEffect } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Link from 'next/link' -import { createClient } from '@/lib/supabase/client' +import { Suspense } from 'react' +import SignupForm from './SignupForm' export default function SignupPage() { - const router = useRouter() - const searchParams = useSearchParams() - // Pre-fill email if provided in URL (from invite link) - const [email, setEmail] = useState(searchParams.get('email') ?? '') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - useEffect(() => { - // Handle invite/signup token from URL hash - // Supabase adds tokens to the URL hash after redirect - const handleTokenFromUrl = async () => { - const hash = window.location.hash - if (hash) { - const params = new URLSearchParams(hash.substring(1)) - const accessToken = params.get('access_token') - const refreshToken = params.get('refresh_token') - const type = params.get('type') - - if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) { - const supabase = createClient() - const { error } = await supabase.auth.setSession({ - access_token: accessToken, - refresh_token: refreshToken, - }) - - if (error) { - setError('Invalid or expired invite link. Please request a new invitation.') - return - } - - // Get the user's email from the session - const { data: { user } } = await supabase.auth.getUser() - if (user?.email) { - setEmail(user.email) - } - } - } - } - - handleTokenFromUrl() - }, [searchParams]) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError(null) - - // Validate passwords match - if (password !== confirmPassword) { - setError('Passwords do not match') - return - } - - // Validate password length - if (password.length < 6) { - setError('Password must be at least 6 characters') - return - } - - setLoading(true) - - const supabase = createClient() - - // Check if user already has a session (from invite link) - const { data: { session } } = await supabase.auth.getSession() - - if (session) { - // User was invited and has a session - update their password - const { error: updateError } = await supabase.auth.updateUser({ - password, - }) - - if (updateError) { - setError(updateError.message) - setLoading(false) - return - } - - // Create profile record - const { error: profileError } = await supabase.from('profiles').upsert({ - id: session.user.id, - email: session.user.email, - display_name: session.user.email?.split('@')[0] || 'User', - is_admin: false, - }) - - if (profileError) { - setError(profileError.message) - setLoading(false) - return - } - - router.push('/dashboard') - } else { - // Regular signup flow (if allowed) - const { data, error } = await supabase.auth.signUp({ - email, - password, - }) - - if (error) { - setError(error.message) - setLoading(false) - return - } - - if (data.user) { - // Create profile record - const { error: profileError } = await supabase.from('profiles').upsert({ - id: data.user.id, - email: data.user.email, - display_name: email.split('@')[0] || 'User', - is_admin: false, - }) - - if (profileError) { - setError(profileError.message) - setLoading(false) - return - } - - router.push('/dashboard') - } - } - } - return (
-
-
-

- WebVNWrite -

-

- Complete your account setup -

-
- -
- {error && ( -
-

{error}

-
- )} - -
-
- - setEmail(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="you@example.com" - /> -
- -
- - setPassword(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="••••••••" - /> -
- -
- - setConfirmPassword(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="••••••••" - /> -
+ +

+ WebVNWrite +

+

+ Loading... +

- - - -

- Already have an account?{' '} - - Sign in - -

-
-
+ } + > + +
) } From dd8fcb79cf7bd54d706502fe76df2552e6caefc1 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:31:48 -0300 Subject: [PATCH 20/25] fix: redirect root page to dashboard instead of default Next.js template Co-Authored-By: Claude Opus 4.5 --- src/app/page.tsx | 64 ++---------------------------------------------- 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..28c5ca1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from 'next/navigation' export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect('/dashboard') } From ff52df2c28e643bc9c9463e278ca0146f2c5ad87 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:47:39 -0300 Subject: [PATCH 21/25] feat: [US-040] - Conditionals on choice options Co-Authored-By: Claude Opus 4.5 --- prd.json | 53 ++++++ progress.txt | 17 ++ .../editor/[projectId]/FlowchartEditor.tsx | 5 + .../editor/OptionConditionEditor.tsx | 171 ++++++++++++++++++ src/components/editor/nodes/ChoiceNode.tsx | 149 ++++++++++++--- src/types/flowchart.ts | 15 +- 6 files changed, 376 insertions(+), 34 deletions(-) create mode 100644 src/components/editor/OptionConditionEditor.tsx diff --git a/prd.json b/prd.json index fa18f0b..dca59fa 100644 --- a/prd.json +++ b/prd.json @@ -694,6 +694,59 @@ "priority": 39, "passes": true, "notes": "" + }, + { + "id": "US-040", + "title": "Conditionals on choice options", + "description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).", + "acceptanceCriteria": [ + "Each ChoiceOption can have optional condition (variableName, operator, value)", + "Update ChoiceNode UI to show 'Add condition' button per option", + "Condition editor modal for each option", + "Visual indicator (icon/badge) on options with conditions", + "Update TypeScript types: ChoiceOption gets optional condition field", + "Export includes per-option conditions in Ren'Py JSON", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 40, + "passes": true, + "notes": "Dependencies: US-018, US-019, US-025. Complexity: M" + }, + { + "id": "US-041", + "title": "Change password for logged-in user", + "description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.", + "acceptanceCriteria": [ + "Settings/profile page accessible from dashboard header", + "Form with: current password, new password, confirm new password fields", + "Calls Supabase updateUser with new password", + "Requires current password verification (re-authenticate)", + "Shows success/error messages", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 41, + "passes": false, + "notes": "Dependencies: US-004. Complexity: S" + }, + { + "id": "US-042", + "title": "Password reset modal on token arrival", + "description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.", + "acceptanceCriteria": [ + "Detect password reset token in URL (from Supabase email link)", + "Show modal/dialog automatically when token present", + "Modal has: new password, confirm password fields", + "Calls Supabase updateUser with token to complete reset", + "On success, close modal and redirect to login", + "On error, show error message", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 42, + "passes": false, + "notes": "Dependencies: US-006. Complexity: S" } ] } diff --git a/progress.txt b/progress.txt index a62938e..280f732 100644 --- a/progress.txt +++ b/progress.txt @@ -586,3 +586,20 @@ - LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts - Link component from next/link is needed in server components for navigation (no useRouter in server components) --- + +## 2026-01-22 - US-040 +- What was implemented: Conditionals on choice options - per-option visibility conditions +- Files changed: + - src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption + - src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management + - src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value) + - src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition) +- **Learnings for future iterations:** + - Per-option conditions use the same Condition type as edge conditions + - Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it + - Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings + - OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs + - In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility + - Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not + - Condition badge below option shows "if variableName operator value" text in compact format +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 60bb57d..707cebc 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -297,6 +297,11 @@ function convertToRenpyFormat( } } + // Per-option condition (visibility condition) takes priority over edge condition + if (option.condition) { + choice.condition = option.condition + } + return choice }) diff --git a/src/components/editor/OptionConditionEditor.tsx b/src/components/editor/OptionConditionEditor.tsx new file mode 100644 index 0000000..23739c5 --- /dev/null +++ b/src/components/editor/OptionConditionEditor.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import type { Condition } from '@/types/flowchart' + +type OptionConditionEditorProps = { + optionId: string + optionLabel: string + condition?: Condition + onSave: (optionId: string, condition: Condition) => void + onRemove: (optionId: string) => void + onCancel: () => void +} + +const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!='] + +export default function OptionConditionEditor({ + optionId, + optionLabel, + condition, + onSave, + onRemove, + onCancel, +}: OptionConditionEditorProps) { + const [variableName, setVariableName] = useState(condition?.variableName ?? '') + const [operator, setOperator] = useState(condition?.operator ?? '==') + const [value, setValue] = useState(condition?.value ?? 0) + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onCancel]) + + const handleSave = useCallback(() => { + if (!variableName.trim()) return + onSave(optionId, { + variableName: variableName.trim(), + operator, + value, + }) + }, [optionId, variableName, operator, value, onSave]) + + const handleRemove = useCallback(() => { + onRemove(optionId) + }, [optionId, onRemove]) + + const hasExistingCondition = !!condition + + return ( +
+
e.stopPropagation()} + > +

+ {hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'} +

+

+ Option: {optionLabel || '(unnamed)'} +

+ +
+ {/* Variable Name Input */} +
+ + setVariableName(e.target.value)} + placeholder="e.g., affection, score, health" + autoFocus + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ + {/* Operator Dropdown */} +
+ + +
+ + {/* Value Number Input */} +
+ + setValue(parseFloat(e.target.value) || 0)} + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100" + /> +
+ + {/* Preview */} + {variableName.trim() && ( +
+ + Show option when: {variableName.trim()} {operator} {value} + +
+ )} +
+ + {/* Action Buttons */} +
+
+ {hasExistingCondition && ( + + )} +
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx index 7073d2d..82bca9b 100644 --- a/src/components/editor/nodes/ChoiceNode.tsx +++ b/src/components/editor/nodes/ChoiceNode.tsx @@ -1,12 +1,15 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useState, ChangeEvent } from 'react' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { nanoid } from 'nanoid' +import type { Condition } from '@/types/flowchart' +import OptionConditionEditor from '@/components/editor/OptionConditionEditor' type ChoiceOption = { id: string label: string + condition?: Condition } type ChoiceNodeData = { @@ -19,6 +22,7 @@ const MAX_OPTIONS = 6 export default function ChoiceNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() + const [editingConditionOptionId, setEditingConditionOptionId] = useState(null) const updatePrompt = useCallback( (e: ChangeEvent) => { @@ -96,6 +100,57 @@ export default function ChoiceNode({ id, data }: NodeProps) { [id, data.options.length, setNodes] ) + const handleSaveCondition = useCallback( + (optionId: string, condition: Condition) => { + 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 + ) + ) + setEditingConditionOptionId(null) + }, + [id, setNodes] + ) + + const handleRemoveCondition = useCallback( + (optionId: string) => { + setNodes((nodes) => + nodes.map((node) => + node.id === id + ? { + ...node, + data: { + ...node.data, + options: node.data.options.map((opt: ChoiceOption) => { + if (opt.id !== optionId) return opt + const updated = { ...opt } + delete updated.condition + return updated + }), + }, + } + : node + ) + ) + setEditingConditionOptionId(null) + }, + [id, setNodes] + ) + + const editingOption = editingConditionOptionId + ? data.options.find((opt) => opt.id === editingConditionOptionId) + : null + return (
) {
{data.options.map((option, index) => ( -
- 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" - /> - - +
+
+ 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" + /> + + + +
+ {option.condition && ( +
+ + if {option.condition.variableName} {option.condition.operator} {option.condition.value} + +
+ )}
))}
@@ -158,6 +242,17 @@ export default function ChoiceNode({ id, data }: NodeProps) { > + Add Option + + {editingOption && ( + setEditingConditionOptionId(null)} + /> + )}
) } diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 21a5c54..50fee50 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -4,6 +4,13 @@ export type Position = { y: number; }; +// Condition type for conditional edges and choice options +export type Condition = { + variableName: string; + operator: '>' | '<' | '==' | '>=' | '<=' | '!='; + value: number; +}; + // DialogueNode type: represents character speech/dialogue export type DialogueNode = { id: string; @@ -19,6 +26,7 @@ export type DialogueNode = { export type ChoiceOption = { id: string; label: string; + condition?: Condition; }; // ChoiceNode type: represents branching decisions @@ -47,13 +55,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; - operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number; -}; - // FlowchartEdge type: represents connections between nodes export type FlowchartEdge = { id: string; From e8a6942cfe3cfd150c344901b21850f38dfe6612 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:49:25 -0300 Subject: [PATCH 22/25] feat: [US-041] - Change password for logged-in user Co-Authored-By: Claude Opus 4.5 --- src/app/dashboard/settings/page.tsx | 161 ++++++++++++++++++++++++++++ src/components/Navbar.tsx | 6 ++ 2 files changed, 167 insertions(+) create mode 100644 src/app/dashboard/settings/page.tsx diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..23898f1 --- /dev/null +++ b/src/app/dashboard/settings/page.tsx @@ -0,0 +1,161 @@ +'use client' + +import { useState } from 'react' +import { createClient } from '@/lib/supabase/client' + +export default function SettingsPage() { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setSuccess('') + + if (newPassword !== confirmPassword) { + setError('New passwords do not match.') + return + } + + if (newPassword.length < 6) { + setError('New password must be at least 6 characters.') + return + } + + setIsLoading(true) + + try { + const supabase = createClient() + + // Re-authenticate with current password + const { data: { user } } = await supabase.auth.getUser() + if (!user?.email) { + setError('Unable to verify current user.') + setIsLoading(false) + return + } + + const { error: signInError } = await supabase.auth.signInWithPassword({ + email: user.email, + password: currentPassword, + }) + + if (signInError) { + setError('Current password is incorrect.') + setIsLoading(false) + return + } + + // Update to new password + const { error: updateError } = await supabase.auth.updateUser({ + password: newPassword, + }) + + if (updateError) { + setError(updateError.message) + setIsLoading(false) + return + } + + setSuccess('Password updated successfully.') + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + } catch { + setError('An unexpected error occurred.') + } finally { + setIsLoading(false) + } + } + + return ( +
+

+ Settings +

+ +
+

+ Change Password +

+ +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ + setCurrentPassword(e.target.value)} + required + className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ +
+ + setNewPassword(e.target.value)} + required + className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ + +
+
+
+ ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index d7d1a57..1e7f032 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) { Invite User )} + + Settings + {userEmail} From d47794ebc9de82babd83a8ec5a2ae0d8979b70d2 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:49:54 -0300 Subject: [PATCH 23/25] chore: mark US-041 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index dca59fa..4aa7581 100644 --- a/prd.json +++ b/prd.json @@ -727,7 +727,7 @@ "Verify in browser using dev-browser skill" ], "priority": 41, - "passes": false, + "passes": true, "notes": "Dependencies: US-004. Complexity: S" }, { diff --git a/progress.txt b/progress.txt index 280f732..b4d0d8b 100644 --- a/progress.txt +++ b/progress.txt @@ -26,6 +26,7 @@ - Use nanoid for generating unique node IDs (import from 'nanoid') - Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message - 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 --- @@ -603,3 +604,17 @@ - Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not - Condition badge below option shows "if variableName operator value" text in compact format --- + +## 2026-01-22 - US-041 +- What was implemented: Change password for logged-in user from settings page +- Files changed: + - src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields) + - src/components/Navbar.tsx - added "Settings" link to navbar +- **Learnings for future iterations:** + - Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check) + - Re-authentication uses signInWithPassword with current password before allowing updateUser + - Supabase getUser() returns current user email needed for re-auth signInWithPassword call + - Password validation: check match and minimum length (6 chars) before making API calls + - Clear form fields after successful password update for security + - Settings link in navbar uses neutral zinc colors to distinguish from admin/action links +--- From 4fbc5649443e38d2174aaec21cdd8ed4ff6e90f4 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:51:52 -0300 Subject: [PATCH 24/25] feat: [US-042] - Password reset modal on token arrival Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 14 +++ src/app/login/page.tsx | 2 + src/components/PasswordResetModal.tsx | 170 ++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/components/PasswordResetModal.tsx diff --git a/prd.json b/prd.json index 4aa7581..196ae27 100644 --- a/prd.json +++ b/prd.json @@ -745,7 +745,7 @@ "Verify in browser using dev-browser skill" ], "priority": 42, - "passes": false, + "passes": true, "notes": "Dependencies: US-006. Complexity: S" } ] diff --git a/progress.txt b/progress.txt index b4d0d8b..c5d0f32 100644 --- a/progress.txt +++ b/progress.txt @@ -618,3 +618,17 @@ - Clear form fields after successful password update for security - Settings link in navbar uses neutral zinc colors to distinguish from admin/action links --- + +## 2026-01-22 - US-042 +- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL +- Files changed: + - src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form + - src/app/login/page.tsx - integrated PasswordResetModal component on the login page +- **Learnings for future iterations:** + - PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens + - Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh) + - Separate tokenError state from form error state to show different UI (expired link vs. form validation) + - Modal uses fixed positioning with z-50 to overlay above page content + - After successful password update, sign out the user and redirect to login with success message (same as reset-password page) + - The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns +--- diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f9033de..231b62a 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import LoginForm from './LoginForm' +import PasswordResetModal from '@/components/PasswordResetModal' export default function LoginPage() { return ( @@ -18,6 +19,7 @@ export default function LoginPage() { > +
) } diff --git a/src/components/PasswordResetModal.tsx b/src/components/PasswordResetModal.tsx new file mode 100644 index 0000000..9a190f7 --- /dev/null +++ b/src/components/PasswordResetModal.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' + +export default function PasswordResetModal() { + const router = useRouter() + const [isOpen, setIsOpen] = useState(false) + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [tokenError, setTokenError] = useState(null) + + useEffect(() => { + const handleTokenFromUrl = async () => { + const hash = window.location.hash + if (!hash) return + + const params = new URLSearchParams(hash.substring(1)) + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + const type = params.get('type') + + if (accessToken && refreshToken && type === 'recovery') { + const supabase = createClient() + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }) + + if (error) { + setTokenError('Invalid or expired reset link. Please request a new password reset.') + return + } + + // Clear hash from URL without reloading + window.history.replaceState(null, '', window.location.pathname + window.location.search) + setIsOpen(true) + } + } + + handleTokenFromUrl() + }, []) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + if (password.length < 6) { + setError('Password must be at least 6 characters') + return + } + + setLoading(true) + + const supabase = createClient() + + const { error: updateError } = await supabase.auth.updateUser({ + password, + }) + + if (updateError) { + setError(updateError.message) + setLoading(false) + return + } + + await supabase.auth.signOut() + + setIsOpen(false) + router.push('/login?message=password_reset_success') + } + + if (tokenError) { + return ( +
+
+

+ Reset link expired +

+

+ {tokenError} +

+ +
+
+ ) + } + + if (!isOpen) return null + + return ( +
+
+

+ Set new password +

+

+ Enter your new password below. +

+ +
+ {error && ( +
+

{error}

+
+ )} + +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ + +
+
+
+ ) +} From 8cdba7f858ff7d701b41e410ecadf18cb6ac6ccc Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Fri, 23 Jan 2026 15:17:08 -0300 Subject: [PATCH 25/25] fix: wrongfull jsx without single parent --- src/app/editor/[projectId]/page.tsx | 3 ++- src/types/flowchart.ts | 8 -------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index ca5abda..f261d4a 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -42,7 +42,7 @@ export default async function EditorPage({ params }: PageProps) { // the project was created before these features existed and may need auto-migration const needsMigration = !rawData.characters && !rawData.variables - return ( + return (<>
@@ -103,6 +103,7 @@ export default async function EditorPage({ params }: PageProps) { needsMigration={needsMigration} />
+ ) } diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 9072a34..4f4461c 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -4,13 +4,6 @@ export type Position = { y: number; }; -<<<<<<< HEAD -// Condition type for conditional edges and choice options -export type Condition = { - variableName: string; - operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number; -======= // Character type: represents a defined character in the project export type Character = { id: string; @@ -34,7 +27,6 @@ export type Condition = { variableId?: string; operator: '>' | '<' | '==' | '>=' | '<=' | '!='; value: number | string | boolean; ->>>>>>> ralph/collaboration-and-character-variables }; // DialogueNode type: represents character speech/dialogue