developing #6

Closed
GHMiranda wants to merge 32 commits from developing into master
16 changed files with 3262 additions and 1057 deletions
Showing only changes of commit 7cb37ff940 - Show all commits

790
prd.json
View File

@ -1,18 +1,19 @@
{
"project": "WebVNWrite",
"branchName": "ralph/vn-flowchart-editor",
"description": "Visual Novel Flowchart Editor - A web-based tool for authoring visual novels with drag-and-drop nodes, branching connections, user authentication, and Ren'Py JSON export",
"branchName": "ralph/collaboration-and-character-variables",
"description": "Real-time Collaboration & Character/Variable Management - Enable multi-user editing with CRDT sync, presence indicators, audit trail, plus centralized character/variable definitions with dropdown selectors",
"userStories": [
{
"id": "US-001",
"title": "Project scaffolding and configuration",
"description": "As a developer, I need the project set up with Next.js, TailwindCSS, and Supabase so that I can build the application.",
"id": "US-054",
"title": "Character and Variable TypeScript types",
"description": "As a developer, I need TypeScript types for Character and Variable models so that the rest of the feature can be built with type safety.",
"acceptanceCriteria": [
"Initialize Next.js project with TypeScript and App Router",
"Install and configure TailwindCSS",
"Install Supabase client library (@supabase/supabase-js)",
"Create .env.example with NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY",
"Basic folder structure: app/, components/, lib/, types/",
"Add Character type to types/flowchart.ts: id (string), name (string), color (string, hex), description (string, optional)",
"Add Variable type to types/flowchart.ts: id (string), name (string), type ('numeric' | 'string' | 'boolean'), initialValue (number | string | boolean), description (string, optional)",
"Update FlowchartData type to include characters: Character[] and variables: Variable[]",
"Update DialogueNodeData to add optional characterId: string field (alongside existing speaker for migration)",
"Update Condition type to add optional variableId: string field (alongside existing variableName for migration)",
"Update VariableNodeData to add optional variableId: string field (alongside existing variableName for migration)",
"Typecheck passes"
],
"priority": 1,
@ -20,680 +21,401 @@
"notes": ""
},
{
"id": "US-002",
"title": "Define TypeScript types for flowchart data",
"description": "As a developer, I need TypeScript types for nodes, connections, and conditions.",
"id": "US-055",
"title": "Database schema update for characters and variables",
"description": "As a developer, I need the database schema to store characters and variables as part of the project's flowchart data.",
"acceptanceCriteria": [
"Create types/flowchart.ts file",
"DialogueNode type: id, type='dialogue', position: {x,y}, data: { speaker?: string, text: string }",
"ChoiceNode type: id, type='choice', position: {x,y}, data: { prompt: string, options: { id: string, label: string }[] }",
"VariableNode type: id, type='variable', position: {x,y}, data: { variableName: string, operation: 'set'|'add'|'subtract', value: number }",
"Condition type: { variableName: string, operator: '>'|'<'|'=='|'>='|'<='|'!=', value: number }",
"FlowchartEdge type: id, source, sourceHandle?, target, targetHandle?, data?: { condition?: Condition }",
"FlowchartData type: { nodes: (DialogueNode|ChoiceNode|VariableNode)[], edges: FlowchartEdge[] }",
"All types exported from types/flowchart.ts",
"Create migration that documents the new JSONB structure (characters/variables arrays stored within flowchart_data)",
"Update the default value for flowchart_data column to include characters: [] and variables: []",
"Existing projects with no characters/variables arrays continue to load (handled as empty arrays in app code)",
"Typecheck passes"
],
"priority": 2,
"passes": true,
"notes": ""
"notes": "Dependencies: US-054"
},
{
"id": "US-003",
"title": "Supabase schema for users and projects",
"description": "As a developer, I need database tables to store users and their projects.",
"id": "US-065",
"title": "Searchable combobox component",
"description": "As a developer, I need a reusable searchable combobox component so that all character/variable dropdowns share consistent behavior and styling.",
"acceptanceCriteria": [
"Create supabase/migrations/ directory",
"Create SQL migration file with profiles table: id (uuid, references auth.users), email (text), display_name (text), is_admin (boolean default false), created_at (timestamptz)",
"Create projects table: id (uuid), user_id (uuid, foreign key to profiles.id), name (text), flowchart_data (jsonb), created_at (timestamptz), updated_at (timestamptz)",
"Add RLS policy: users can SELECT/INSERT/UPDATE/DELETE their own projects (user_id = auth.uid())",
"Add RLS policy: users can SELECT their own profile",
"Add RLS policy: admin users (is_admin=true) can SELECT all profiles",
"Typecheck passes"
"Create components/editor/Combobox.tsx - a reusable searchable dropdown component",
"Props: items (id, label, color?, badge?), value, onChange, placeholder, onAddNew (optional callback)",
"Typing in the input filters the list by name (case-insensitive)",
"Keyboard navigation: arrow keys to move, Enter to select, Escape to close",
"Shows color swatch and/or badge next to item labels when provided",
"'Add new...' option rendered at bottom when onAddNew prop is provided",
"Dropdown positions itself above or below input based on available space",
"Matches existing editor styling (TailwindCSS, dark mode support)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 3,
"passes": true,
"notes": ""
},
{
"id": "US-004",
"title": "Supabase client configuration",
"description": "As a developer, I need Supabase client utilities for auth and database access.",
"id": "US-056",
"title": "Character management UI in project settings",
"description": "As a user, I want a dedicated page to manage my project's characters so that I can define them once and reuse them throughout the flowchart.",
"acceptanceCriteria": [
"Create lib/supabase/client.ts with browser client (createBrowserClient)",
"Create lib/supabase/server.ts with server client (createServerClient for App Router)",
"Create lib/supabase/middleware.ts with middleware client helper",
"Export typed database client using generated types or manual types",
"Typecheck passes"
"Add 'Project Settings' button to editor toolbar",
"Project settings opens as a modal with 'Characters' and 'Variables' tabs",
"Characters tab shows a list of defined characters with name, color swatch, and description",
"'Add Character' button opens inline form with: name (required), color picker (required, defaults to random), description (optional)",
"Each character row has Edit and Delete buttons",
"Deleting a character referenced by nodes shows warning with usage count",
"Character names must be unique within the project (validation error if duplicate)",
"Changes are saved to the flowchart data (same save mechanism as nodes/edges)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 4,
"passes": true,
"notes": ""
"notes": "Dependencies: US-054, US-055"
},
{
"id": "US-005",
"title": "Protected routes middleware",
"description": "As a developer, I need authentication middleware so that only logged-in users can access the app.",
"id": "US-057",
"title": "Variable management UI in project settings",
"description": "As a user, I want a dedicated page to manage my project's variables so that I can define them with types and initial values for use throughout the flowchart.",
"acceptanceCriteria": [
"Create middleware.ts at project root",
"Middleware checks Supabase session on each request",
"Unauthenticated users accessing /dashboard or /editor/* are redirected to /login",
"Authenticated users accessing /login or /signup are redirected to /dashboard",
"Public routes allowed without auth: /login, /signup, /forgot-password, /reset-password",
"Typecheck passes"
"Variables tab in project settings modal shows a list of defined variables",
"Each variable displays: name, type badge (numeric/string/boolean), initial value, description",
"'Add Variable' button opens inline form with: name (required), type dropdown (required), initial value (required, input adapts to type), description (optional)",
"Each variable row has Edit and Delete buttons",
"Deleting a variable referenced by nodes/edges shows warning with usage count",
"Variable names must be unique within the project",
"Changes are saved to the flowchart data",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 5,
"passes": true,
"notes": ""
"notes": "Dependencies: US-054, US-055"
},
{
"id": "US-006",
"title": "Login page",
"description": "As a user, I want to log in with my email and password so that I can access my projects.",
"id": "US-058",
"title": "Dialogue node speaker dropdown",
"description": "As a user, I want to select a character from a dropdown in the dialogue node instead of typing a name so that I avoid typos and maintain consistency.",
"acceptanceCriteria": [
"Create app/login/page.tsx",
"Form with email and password input fields",
"Submit button calls Supabase signInWithPassword",
"Show error message for invalid credentials",
"On success, redirect to /dashboard",
"Link to /forgot-password page",
"Styled with TailwindCSS",
"Replace the speaker text input in DialogueNode with the Combobox component",
"Dropdown lists all characters defined in the project, showing color swatch + name",
"Selecting a character sets characterId on the node data",
"Dropdown includes 'Add new character...' option at the bottom",
"Clicking 'Add new character...' opens a mini form inline (name + color) that creates the character and selects it",
"If node has a characterId that doesn't match any defined character, show orange warning border on the dropdown",
"Empty/unset speaker shows placeholder 'Select speaker...'",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 6,
"passes": true,
"notes": ""
"notes": "Dependencies: US-056, US-065"
},
{
"id": "US-007",
"title": "Sign up page (invite-only)",
"description": "As an invited user, I want to complete my account setup so that I can access the tool.",
"id": "US-059",
"title": "Variable node variable dropdown",
"description": "As a user, I want to select a variable from a dropdown in the variable node instead of typing a name so that I avoid typos and maintain consistency.",
"acceptanceCriteria": [
"Create app/signup/page.tsx",
"Form with email (pre-filled if from invite link), password, and confirm password fields",
"Validate passwords match before submission",
"Handle Supabase invite token from URL (type=invite or type=signup)",
"On success, create profile record in profiles table and redirect to /dashboard",
"Show error message if signup fails",
"Styled with TailwindCSS",
"Replace the variableName text input in VariableNode with the Combobox component",
"Dropdown lists all variables defined in the project, showing type badge + name",
"Selecting a variable sets variableId on the node data",
"Dropdown includes 'Add new variable...' option that opens inline creation form",
"If node references a variableId that doesn't match any defined variable, show orange warning border",
"Operation options (set/add/subtract) are filtered based on selected variable's type (add/subtract only for numeric)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 7,
"passes": true,
"notes": ""
"notes": "Dependencies: US-057, US-065"
},
{
"id": "US-008",
"title": "Logout functionality",
"description": "As a user, I want to log out so that I can secure my session.",
"id": "US-060",
"title": "Edge condition variable dropdown",
"description": "As a user, I want to select a variable from a dropdown when setting edge conditions so that I reference valid variables consistently.",
"acceptanceCriteria": [
"Create components/LogoutButton.tsx component",
"Button calls Supabase signOut",
"On success, redirect to /login",
"Replace the variableName text input in ConditionEditor with the Combobox component",
"Dropdown lists all variables defined in the project, showing type badge + name",
"Selecting a variable sets variableId on the condition object",
"Dropdown includes 'Add new variable...' option",
"If condition references an undefined variableId, show orange warning indicator",
"Operator options are filtered based on variable type (comparison operators for numeric, == and != for string/boolean)",
"Value input adapts to variable type (number input for numeric, text for string, checkbox for boolean)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 8,
"passes": true,
"notes": ""
"notes": "Dependencies: US-057, US-065"
},
{
"id": "US-009",
"title": "Password reset - forgot password page",
"description": "As a user, I want to request a password reset if I forget my password.",
"id": "US-061",
"title": "Choice option condition variable dropdown",
"description": "As a user, I want to select a variable from a dropdown when setting choice option conditions so that I reference valid variables consistently.",
"acceptanceCriteria": [
"Create app/forgot-password/page.tsx",
"Form with email input field",
"Submit button calls Supabase resetPasswordForEmail",
"Show confirmation message after sending (check your email)",
"Link back to /login",
"Styled with TailwindCSS",
"Replace the variableName text input in OptionConditionEditor with the Combobox component",
"Dropdown lists all variables defined in the project, showing type badge + name",
"Selecting a variable sets variableId on the option's condition object",
"Dropdown includes 'Add new variable...' option",
"If condition references an undefined variableId, show orange warning indicator",
"Operator and value inputs adapt to variable type (same behavior as US-060)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 9,
"passes": true,
"notes": ""
"notes": "Dependencies: US-057, US-065"
},
{
"id": "US-010",
"title": "Password reset - set new password page",
"description": "As a user, I want to set a new password after clicking the reset link.",
"id": "US-062",
"title": "Auto-migration of existing free-text values",
"description": "As a user, I want my existing projects to automatically create character and variable definitions from free-text values so that I don't have to manually re-enter them.",
"acceptanceCriteria": [
"Create app/reset-password/page.tsx",
"Form with new password and confirm password fields",
"Handle Supabase recovery token from URL",
"Submit calls Supabase updateUser with new password",
"On success, redirect to /login with success message",
"Show error if token invalid or expired",
"Styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
"On project load, if characters array is empty but nodes have speaker values, auto-create Character entries from unique speaker names",
"Auto-created characters get randomly assigned colors and the speaker text as name",
"On project load, if variables array is empty but nodes/edges have variableName values, auto-create Variable entries (default type: numeric, initial value: 0)",
"After auto-creation, update all nodes to set characterId/variableId references pointing to the new entries",
"Show a toast notification: 'Auto-imported N characters and M variables from existing data'",
"Migration only runs once (presence of characters/variables arrays, even if empty, means migration already happened)",
"Typecheck passes"
],
"priority": 10,
"passes": true,
"notes": ""
"notes": "Dependencies: US-054, US-058, US-059"
},
{
"id": "US-011",
"title": "Dashboard layout with navbar",
"description": "As a user, I want a consistent layout with navigation so that I can move around the app.",
"id": "US-063",
"title": "Import characters/variables from another project",
"description": "As a user, I want to import character and variable definitions from another project so that I can reuse them without redefining everything.",
"acceptanceCriteria": [
"Create app/dashboard/layout.tsx",
"Navbar component with app title/logo",
"Navbar shows current user email",
"Navbar includes LogoutButton",
"Main content area below navbar",
"Styled with TailwindCSS",
"Add 'Import from project' button in both Characters and Variables tabs of project settings",
"Button opens a modal listing the user's other projects",
"Selecting a project shows its characters (or variables) with checkboxes for selection",
"User can select which entries to import (select all / none / individual)",
"Imported entries are added to the current project (duplicates by name are skipped with a warning)",
"Imported characters keep their colors; imported variables keep their types and initial values",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 11,
"passes": true,
"notes": ""
"notes": "Dependencies: US-056, US-057"
},
{
"id": "US-012",
"title": "Dashboard - list projects",
"description": "As a user, I want to see all my projects so that I can choose which one to edit.",
"id": "US-064",
"title": "Export validation for undefined references",
"description": "As a user, I want to be warned before exporting if any nodes reference undefined characters or variables so that I can fix issues before generating output.",
"acceptanceCriteria": [
"Create app/dashboard/page.tsx",
"Fetch projects from Supabase for current user",
"Display projects as cards in a grid",
"Each card shows: project name, last updated date (formatted)",
"Click card navigates to /editor/[projectId]",
"Empty state with message when no projects exist",
"Loading state while fetching",
"Before export, scan all nodes and edges for characterId/variableId references that don't match defined entries",
"If issues found, show a warning modal listing: node type, node content snippet, and the undefined reference",
"Modal offers 'Export anyway' and 'Cancel' options",
"Nodes with undefined references are highlighted on the canvas with orange warning borders when modal is shown",
"If no issues found, export proceeds normally",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 12,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-058, US-059, US-060, US-061"
},
{
"id": "US-013",
"title": "Create new project",
"description": "As a user, I want to create a new project so that I can start a new flowchart.",
"id": "US-043",
"title": "Database schema for collaboration sessions and audit trail",
"description": "As a developer, I need database tables to track active collaboration sessions and store the full change history for projects.",
"acceptanceCriteria": [
"Add 'New Project' button on dashboard",
"Clicking opens modal with project name input",
"Submit creates project in Supabase with empty flowchart_data: { nodes: [], edges: [] }",
"On success, redirect to /editor/[newProjectId]",
"Show error if creation fails",
"Typecheck passes",
"Verify in browser using dev-browser skill"
"Create migration adding project_collaborators table: id (uuid), project_id (references projects), user_id (references profiles), role ('owner' | 'editor' | 'viewer'), invited_at (timestamptz), accepted_at (timestamptz)",
"Create collaboration_sessions table: id (uuid), project_id, user_id, cursor_position (jsonb), selected_node_id (text nullable), connected_at (timestamptz), last_heartbeat (timestamptz)",
"Create audit_trail table: id (uuid), project_id, user_id, action_type (text: 'node_add' | 'node_update' | 'node_delete' | 'edge_add' | 'edge_update' | 'edge_delete'), entity_id (text), previous_state (jsonb), new_state (jsonb), created_at (timestamptz)",
"Add RLS policies: collaborators can access sessions/audit for projects they belong to",
"Add index on audit_trail(project_id, created_at) for efficient history queries",
"Typecheck passes"
],
"priority": 13,
"passes": true,
"passes": false,
"notes": ""
},
{
"id": "US-014",
"title": "Delete project",
"description": "As a user, I want to delete a project I no longer need.",
"id": "US-045",
"title": "Supabase Realtime channel and connection management",
"description": "As a developer, I need a WebSocket connection layer using Supabase Realtime so that clients can exchange presence and change events in real time.",
"acceptanceCriteria": [
"Add delete icon/button on each project card",
"Clicking shows confirmation dialog (Are you sure?)",
"Confirm deletes project from Supabase",
"Project removed from dashboard list without page reload",
"Show success toast after deletion",
"Create lib/collaboration/realtime.ts module",
"On editor mount, join a Supabase Realtime channel scoped to the project ID",
"Track connection state (connecting, connected, disconnected, reconnecting)",
"Implement heartbeat mechanism (update last_heartbeat every 30 seconds)",
"Auto-reconnect on network interruption with exponential backoff",
"Clean up session record on disconnect/unmount",
"Show connection status indicator in editor toolbar (green=connected, yellow=reconnecting, red=disconnected)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 14,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-043"
},
{
"id": "US-015",
"title": "Rename project",
"description": "As a user, I want to rename a project to keep my work organized.",
"id": "US-044",
"title": "Project sharing and collaborator management",
"description": "As a project owner, I want to invite other users to collaborate on my project so that we can work together.",
"acceptanceCriteria": [
"Add edit/rename icon on project card",
"Clicking opens modal or enables inline edit for project name",
"Submit updates project name in Supabase",
"UI updates immediately without page reload",
"Show error if rename fails",
"Add 'Share' button in the editor toolbar",
"Share modal displays current collaborators with roles (owner/editor/viewer)",
"Owner can invite users by email with a selected role",
"Owner can change collaborator roles or remove collaborators",
"Invited users see shared projects on their dashboard with a 'Shared with me' indicator",
"RLS policies updated so collaborators can read/write projects based on their role",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 15,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-043"
},
{
"id": "US-016",
"title": "Admin - invite new user",
"description": "As an admin, I want to invite new users so that collaborators can access the tool.",
"id": "US-046",
"title": "Presence indicators for active collaborators",
"description": "As a user, I want to see who else is currently viewing or editing the project so that I am aware of my collaborators.",
"acceptanceCriteria": [
"Create app/admin/invite/page.tsx",
"Only accessible by users with is_admin=true (redirect others to /dashboard)",
"Form with email address input",
"Submit calls Supabase admin inviteUserByEmail (requires service role key in server action)",
"Show success message with invite sent confirmation",
"Show error if invite fails",
"Link to this page visible in navbar only for admins",
"Display a row of avatar circles in the editor toolbar showing connected users",
"Each avatar shows the user's display_name on hover (tooltip)",
"Each user is assigned a consistent color (derived from user ID hash)",
"Avatars appear when users join and disappear when they leave",
"Maximum 5 avatars shown with '+N' overflow indicator",
"Own avatar not shown in the list",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 16,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-045"
},
{
"id": "US-017",
"title": "Editor page with React Flow canvas",
"description": "As a user, I want an editor page with a canvas where I can build my flowchart.",
"id": "US-048",
"title": "Integrate Yjs CRDT for conflict-free node/edge synchronization",
"description": "As a developer, I need to integrate a CRDT library so that concurrent edits from multiple users merge automatically without data loss.",
"acceptanceCriteria": [
"Install reactflow package",
"Create app/editor/[projectId]/page.tsx",
"Fetch project from Supabase by ID",
"Show error if project not found or user unauthorized",
"Show loading state while fetching",
"Render React Flow canvas filling the editor area",
"Canvas has grid background (React Flow Background component)",
"Header shows project name with back link to /dashboard",
"Initialize React Flow with nodes and edges from flowchart_data",
"Typecheck passes",
"Verify in browser using dev-browser skill"
"Install and configure Yjs with a Supabase-compatible provider (or WebSocket provider)",
"Create lib/collaboration/crdt.ts module wrapping Yjs document setup",
"Model flowchart nodes as a Y.Map keyed by node ID",
"Model flowchart edges as a Y.Map keyed by edge ID",
"Local React Flow state changes are synced to the Yjs document",
"Remote Yjs document changes update local React Flow state",
"Initial load populates Yjs document from database state",
"Periodic persistence of Yjs document state to Supabase (debounced 2 seconds)",
"Typecheck passes"
],
"priority": 17,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-045"
},
{
"id": "US-018",
"title": "Canvas pan and zoom controls",
"description": "As a user, I want to pan and zoom the canvas to navigate large flowcharts.",
"id": "US-047",
"title": "Live cursor positions on canvas",
"description": "As a user, I want to see other collaborators' cursor positions on the canvas so that I can understand where they are working.",
"acceptanceCriteria": [
"Canvas supports click-and-drag panning (React Flow default)",
"Mouse wheel zooms in/out (React Flow default)",
"Add React Flow Controls component with zoom +/- buttons",
"Add fitView button to show all nodes",
"Controls positioned in bottom-right corner",
"Broadcast local cursor position to the Realtime channel (throttled to 50ms)",
"Render remote cursors as colored arrows/pointers on the canvas with user name labels",
"Cursor color matches the user's assigned presence color",
"Remote cursors smoothly interpolate between position updates (no jumping)",
"Remote cursors fade out after 5 seconds of inactivity",
"Cursors are rendered in screen coordinates and properly transform with canvas zoom/pan",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 18,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-045, US-046"
},
{
"id": "US-019",
"title": "Editor toolbar",
"description": "As a user, I want a toolbar with actions for adding nodes and saving/exporting.",
"id": "US-050",
"title": "Join/leave notifications",
"description": "As a user, I want to be notified when collaborators join or leave the editing session so that I stay aware of the team's activity.",
"acceptanceCriteria": [
"Create components/editor/Toolbar.tsx",
"Toolbar positioned at top of editor below header",
"Buttons: Add Dialogue, Add Choice, Add Variable (no functionality yet)",
"Buttons: Save, Export, Import (no functionality yet)",
"Buttons styled with TailwindCSS, icons optional",
"Show a toast notification when a collaborator joins: '[Name] joined'",
"Show a toast notification when a collaborator leaves: '[Name] left'",
"Notifications use the collaborator's assigned color as an accent",
"Notifications auto-dismiss after 3 seconds (matches existing Toast behavior)",
"No notification shown for own join/leave events",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 19,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-045, US-046"
},
{
"id": "US-020",
"title": "Create custom dialogue node component",
"description": "As a user, I want dialogue nodes to display and edit character speech.",
"id": "US-049",
"title": "Node editing lock indicators",
"description": "As a user, I want to see when another collaborator is actively editing a specific node so that I can avoid conflicts and wait for them to finish.",
"acceptanceCriteria": [
"Create components/editor/nodes/DialogueNode.tsx",
"Node styled with blue background/border",
"Displays editable input for speaker name (placeholder: 'Speaker')",
"Displays editable textarea for dialogue text (placeholder: 'Dialogue text...')",
"Has one Handle at top (type='target', id='input')",
"Has one Handle at bottom (type='source', id='output')",
"Register as custom node type in React Flow",
"When a user focuses/opens a node for editing, broadcast the node ID to the channel",
"Nodes being edited by others show a colored border matching the editor's presence color",
"A small label with the editor's name appears on the locked node",
"Other users can still view but see a 'Being edited by [name]' indicator if they try to edit",
"Lock is released when the user clicks away, closes the node, or disconnects",
"Lock auto-expires after 60 seconds of inactivity as a safety measure",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 20,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-045, US-048"
},
{
"id": "US-021",
"title": "Add dialogue node from toolbar",
"description": "As a user, I want to add dialogue nodes by clicking the toolbar button.",
"id": "US-051",
"title": "Audit trail recording",
"description": "As a developer, I need all node and edge changes to be recorded in the audit trail so that users can review history and revert changes.",
"acceptanceCriteria": [
"Clicking 'Add Dialogue' in toolbar creates new DialogueNode",
"Node appears at center of current viewport",
"Node has unique ID (use nanoid or uuid)",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
"Every node add/update/delete operation writes a record to audit_trail table",
"Every edge add/update/delete operation writes a record to audit_trail table",
"Records include previous_state (null for additions) and new_state (null for deletions)",
"Records include the acting user's ID and timestamp",
"Writes are batched/debounced to avoid excessive DB calls (max 1 write per second per entity)",
"Audit writes do not block the user's editing flow (fire-and-forget with error logging)",
"Typecheck passes"
],
"priority": 21,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-043, US-048"
},
{
"id": "US-022",
"title": "Create custom choice node component",
"description": "As a user, I want choice nodes to display branching decisions.",
"id": "US-052",
"title": "Activity history sidebar",
"description": "As a user, I want to view a history of all changes made to the project so that I can see what collaborators have done and when.",
"acceptanceCriteria": [
"Create components/editor/nodes/ChoiceNode.tsx",
"Node styled with green background/border",
"Displays editable input for prompt text (placeholder: 'What do you choose?')",
"Displays 2 default options, each with editable label input",
"Has one Handle at top (type='target', id='input')",
"Each option has its own Handle at bottom (type='source', id='option-0', 'option-1', etc.)",
"Register as custom node type in React Flow",
"Add 'History' button to editor toolbar that opens a right sidebar panel",
"Sidebar displays a chronological list of changes with: user name, action type, entity description, timestamp",
"Entries are grouped by time period (Today, Yesterday, Earlier)",
"Each entry shows the user's presence color as an accent",
"Clicking an entry highlights/selects the affected node or edge on the canvas",
"Paginated loading (20 entries per page) with 'Load more' button",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 22,
"passes": true,
"notes": ""
"passes": false,
"notes": "Dependencies: US-051"
},
{
"id": "US-023",
"title": "Add choice node from toolbar",
"description": "As a user, I want to add choice nodes by clicking the toolbar button.",
"id": "US-053",
"title": "Revert changes from audit trail",
"description": "As a user, I want to revert a specific change from the history so that I can undo mistakes made by myself or collaborators.",
"acceptanceCriteria": [
"Clicking 'Add Choice' in toolbar creates new ChoiceNode",
"Node appears at center of current viewport",
"Node has unique ID",
"Node initialized with 2 options (each with unique id and empty label)",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Each entry in the activity history sidebar has a 'Revert' button",
"Clicking 'Revert' shows a confirmation dialog with before/after preview",
"Reverting a node addition deletes the node",
"Reverting a node update restores the previous state",
"Reverting a node deletion re-creates the node with its previous state",
"Reverting an edge change follows the same add/update/delete logic",
"The revert itself is recorded as a new audit trail entry",
"Reverted state is synced to all connected clients via CRDT",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 23,
"passes": true,
"notes": ""
},
{
"id": "US-024",
"title": "Add/remove choice options",
"description": "As a user, I want to add or remove choice options (2-6 options supported).",
"acceptanceCriteria": [
"ChoiceNode has '+' button to add new option",
"Maximum 6 options (button disabled or hidden at max)",
"Each option has 'x' button to remove it",
"Minimum 2 options (remove button disabled or hidden at min)",
"Adding option creates new output Handle dynamically",
"Removing option removes its Handle",
"Node data updates in React Flow state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 24,
"passes": true,
"notes": ""
},
{
"id": "US-025",
"title": "Create custom variable node component",
"description": "As a user, I want variable nodes to set or modify story variables.",
"acceptanceCriteria": [
"Create components/editor/nodes/VariableNode.tsx",
"Node styled with orange background/border",
"Displays editable input for variable name (placeholder: 'variableName')",
"Displays dropdown/select for operation: set, add, subtract",
"Displays editable number input for value (default: 0)",
"Has one Handle at top (type='target', id='input')",
"Has one Handle at bottom (type='source', id='output')",
"Register as custom node type in React Flow",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 25,
"passes": true,
"notes": ""
},
{
"id": "US-026",
"title": "Add variable node from toolbar",
"description": "As a user, I want to add variable nodes by clicking the toolbar button.",
"acceptanceCriteria": [
"Clicking 'Add Variable' in toolbar creates new VariableNode",
"Node appears at center of current viewport",
"Node has unique ID",
"Node initialized with empty variableName, operation='set', value=0",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 26,
"passes": true,
"notes": ""
},
{
"id": "US-027",
"title": "Connect nodes with edges",
"description": "As a user, I want to connect nodes with arrows to define story flow.",
"acceptanceCriteria": [
"Dragging from source Handle to target Handle creates edge (React Flow default)",
"Edges render as smooth bezier curves (default edge type or smoothstep)",
"Edges show arrow marker indicating direction (markerEnd)",
"Edges update position when nodes are moved",
"Cannot connect source-to-source or target-to-target (React Flow handles this)",
"New edges added to React Flow edges state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 27,
"passes": true,
"notes": ""
},
{
"id": "US-028",
"title": "Select and delete nodes",
"description": "As a user, I want to delete nodes to revise my flowchart.",
"acceptanceCriteria": [
"Clicking a node selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected node(s)",
"Deleting node also removes all connected edges",
"Use onNodesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 28,
"passes": true,
"notes": ""
},
{
"id": "US-029",
"title": "Select and delete edges",
"description": "As a user, I want to delete connections between nodes.",
"acceptanceCriteria": [
"Clicking an edge selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected edge(s)",
"Use onEdgesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 29,
"passes": true,
"notes": ""
},
{
"id": "US-030",
"title": "Right-click context menu",
"description": "As a user, I want a context menu for quick actions.",
"acceptanceCriteria": [
"Create components/editor/ContextMenu.tsx",
"Right-click on canvas shows menu: Add Dialogue, Add Choice, Add Variable",
"New node created at click position",
"Right-click on node shows menu: Delete",
"Right-click on edge shows menu: Delete, Add Condition",
"Clicking elsewhere or pressing Escape closes menu",
"Menu styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 30,
"passes": false,
"notes": ""
},
{
"id": "US-031",
"title": "Condition editor modal",
"description": "As a user, I want to add conditions to edges so branches depend on variables.",
"acceptanceCriteria": [
"Create components/editor/ConditionEditor.tsx modal/popover",
"Opens on double-click edge or via context menu 'Add Condition'",
"Form fields: variable name input, operator dropdown (>, <, ==, >=, <=, !=), value number input",
"Pre-fill fields if edge already has condition",
"Save button applies condition to edge data",
"Clear/Remove button removes condition from edge",
"Cancel button closes without saving",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 31,
"passes": false,
"notes": ""
},
{
"id": "US-032",
"title": "Display conditions on edges",
"description": "As a user, I want to see conditions displayed on edges.",
"acceptanceCriteria": [
"Create custom edge component or use edge labels",
"Edges with conditions render as dashed lines (strokeDasharray)",
"Condition label displayed on edge (e.g., 'score > 5')",
"Unconditional edges remain solid lines",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 32,
"passes": false,
"notes": ""
},
{
"id": "US-033",
"title": "Auto-save to LocalStorage",
"description": "As a user, I want my work auto-saved locally so I don't lose progress if the browser crashes.",
"acceptanceCriteria": [
"Save flowchart state (nodes + edges) to LocalStorage on every change",
"Debounce saves (e.g., 1 second delay after last change)",
"LocalStorage key format: 'vnwrite-draft-{projectId}'",
"On editor load, check LocalStorage for saved draft",
"If local draft exists and differs from database, show prompt to restore or discard",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 33,
"passes": false,
"notes": ""
},
{
"id": "US-034",
"title": "Save project to database",
"description": "As a user, I want to save my project to the database manually.",
"acceptanceCriteria": [
"Clicking 'Save' in toolbar saves current nodes/edges to Supabase",
"Update project's flowchart_data and updated_at fields",
"Show saving indicator/spinner while in progress",
"Show success toast on completion",
"Clear LocalStorage draft after successful save",
"Show error toast if save fails",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 34,
"passes": false,
"notes": ""
},
{
"id": "US-035",
"title": "Export project as .vnflow file",
"description": "As a user, I want to export my project as a JSON file for backup or sharing.",
"acceptanceCriteria": [
"Clicking 'Export' in toolbar triggers file download",
"File named '[project-name].vnflow'",
"File contains JSON with nodes and edges arrays",
"JSON is pretty-printed (2-space indent) for readability",
"Uses browser download API (create blob, trigger download)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 35,
"passes": false,
"notes": ""
},
{
"id": "US-036",
"title": "Import project from .vnflow file",
"description": "As a user, I want to import a .vnflow file to restore or share projects.",
"acceptanceCriteria": [
"Clicking 'Import' in toolbar opens file picker",
"Accept .vnflow and .json file extensions",
"If current project has unsaved changes, show confirmation dialog",
"Validate imported file has nodes and edges arrays",
"Show error toast if file is invalid",
"Load valid data into React Flow state (replaces current flowchart)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 36,
"passes": false,
"notes": ""
},
{
"id": "US-037",
"title": "Export to Ren'Py JSON format",
"description": "As a user, I want to export my flowchart to Ren'Py-compatible JSON for use in my game.",
"acceptanceCriteria": [
"Add 'Export to Ren'Py' option (button or dropdown item)",
"File named '[project-name]-renpy.json'",
"Dialogue nodes export as: { type: 'dialogue', speaker: '...', text: '...' }",
"Choice nodes export as: { type: 'menu', prompt: '...', choices: [{ label: '...', next: '...' }] }",
"Variable nodes export as: { type: 'variable', name: '...', operation: '...', value: ... }",
"Edges with conditions include condition object on the choice/jump",
"Organize nodes into labeled sections based on flow (traverse from first node)",
"Include metadata: projectName, exportedAt timestamp",
"Output JSON is valid (test with JSON.parse)",
"Typecheck passes"
],
"priority": 37,
"passes": false,
"notes": ""
},
{
"id": "US-038",
"title": "Unsaved changes warning",
"description": "As a user, I want a warning before losing unsaved work.",
"acceptanceCriteria": [
"Track dirty state: true when flowchart modified after last save",
"Set dirty=true on node/edge add, delete, or modify",
"Set dirty=false after successful save",
"Browser beforeunload event shows warning if dirty",
"Navigating to dashboard shows confirmation modal if dirty",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 38,
"passes": false,
"notes": ""
},
{
"id": "US-039",
"title": "Loading and error states",
"description": "As a user, I want clear feedback when things are loading or when errors occur.",
"acceptanceCriteria": [
"Loading spinner component for async operations",
"Editor shows loading spinner while fetching project",
"Error message displayed if project fails to load (with back to dashboard link)",
"Toast notification system for success/error messages",
"Save error shows toast with retry option",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 39,
"passes": false,
"notes": ""
"notes": "Dependencies: US-052, US-048"
}
]
}

View File

@ -24,412 +24,162 @@
- Register custom node types in nodeTypes object (memoized with useMemo) and pass to ReactFlow component
- FlowchartEditor uses ReactFlowProvider wrapper + inner component pattern for useReactFlow() hook access
- 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
- 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.
---
## 2026-01-21 - US-001
- What was implemented: Project scaffolding and configuration
## 2026-01-23 - US-054
- What was implemented: Character and Variable TypeScript types added to `src/types/flowchart.ts`
- Files changed:
- package.json - project dependencies and scripts
- tsconfig.json - TypeScript configuration
- next.config.ts - Next.js configuration
- postcss.config.mjs - PostCSS with TailwindCSS
- eslint.config.mjs - ESLint configuration
- .env.example - environment variables template
- .gitignore - git ignore rules
- src/app/ - Next.js App Router pages
- src/components/.gitkeep - components directory placeholder
- src/lib/.gitkeep - lib directory placeholder
- src/types/.gitkeep - types directory placeholder
- `src/types/flowchart.ts` - Added `Character`, `Variable`, `DialogueNodeData`, `VariableNodeData` types; updated `FlowchartData`, `DialogueNode`, `VariableNode`, `Condition` types
- `src/app/editor/[projectId]/page.tsx` - Updated FlowchartData initialization to include `characters: []` and `variables: []` defaults
- **Learnings for future iterations:**
- Next.js 16 uses `@tailwindcss/postcss` for TailwindCSS 4 integration
- Use --src-dir flag for create-next-app to put source in src/ folder
- npm package names can't have capital letters (use lowercase)
- .gitignore needs explicit exclusion for .env files, but include .env.example
- The node components (`DialogueNode.tsx`, `VariableNode.tsx`, `ChoiceNode.tsx`) define their own local data types that mirror the global types. When adding fields, both the global type and local component type may need updating in later stories.
- `flowchart_data` is a JSONB column in Supabase, so it comes as `any` type. Always provide defaults for new fields when reading from DB to handle existing data without those fields.
- The new `characterId` and `variableId` fields are optional alongside existing `speaker`/`variableName` fields to support migration from free-text to referenced-entity pattern.
---
## 2026-01-21 - US-002
- What was implemented: TypeScript types for flowchart data structures
## 2026-01-23 - US-055
- What was implemented: Database migration to update flowchart_data JSONB default to include `characters: []` and `variables: []`
- Files changed:
- src/types/flowchart.ts - new file with all flowchart type definitions
- package.json - added typecheck script (tsc --noEmit)
- `supabase/migrations/20260123000000_add_characters_variables_to_flowchart_data.sql` - New migration that alters the default value for the flowchart_data column and documents the expected JSONB structure
- **Learnings for future iterations:**
- Position is a helper type for {x, y} coordinates used by nodes
- FlowchartNode is a union type of DialogueNode | ChoiceNode | VariableNode
- ChoiceOption is a separate type to make options array cleaner
- All types use `export type` for TypeScript isolatedModules compatibility
- Since characters and variables are stored within the existing flowchart_data JSONB column (not as separate tables), schema changes are minimal - just updating the column default. The real data integrity is handled at the application layer.
- The app-side defaults in page.tsx (from US-054) already handle existing projects gracefully, so no data migration of existing rows is needed.
- For JSONB-embedded arrays, the pattern is: update the DB default for new rows + handle missing fields in app code for old rows.
---
## 2026-01-21 - US-003
- What was implemented: Supabase schema for users and projects
## 2026-01-23 - US-065
- What was implemented: Reusable searchable combobox component at `src/components/editor/Combobox.tsx`
- Files changed:
- supabase/migrations/20260121000000_create_profiles_and_projects.sql - new file with all database schema
- `src/components/editor/Combobox.tsx` - New component with searchable dropdown, keyboard navigation, color swatches, badges, "Add new..." option, and auto-positioning
- **Learnings for future iterations:**
- Supabase migrations are plain SQL files in supabase/migrations/ directory
- Migration filenames use timestamp prefix (YYYYMMDDHHMMSS_description.sql)
- RLS policies need separate policies for SELECT, INSERT, UPDATE, DELETE operations
- Admin check policy uses EXISTS subquery to check is_admin flag on profiles table
- projects table references profiles.id (not auth.users.id directly) for proper FK relationships
- flowchart_data column uses JSONB type with default empty structure
- Added auto-update trigger for updated_at timestamp on projects table
- The Combobox exports both the default component and the `ComboboxItem` type for consumers to use
- Props: `items` (ComboboxItem[]), `value` (string | undefined), `onChange` (id: string) => void, `placeholder` (string), `onAddNew` (() => void, optional)
- ComboboxItem shape: `{ id: string, label: string, color?: string, badge?: string }`
- The component uses neutral zinc colors for borders/backgrounds (not blue/green/orange) so it can be reused across different node types
- Dropdown auto-positions above or below based on available viewport space (200px threshold)
- Keyboard: ArrowDown/Up navigate, Enter selects, Escape closes
- The component is designed to be a drop-in replacement for text inputs in node components (same `w-full` and `text-sm` sizing)
---
## 2026-01-21 - US-004
- What was implemented: Supabase client configuration utilities
## 2026-01-23 - US-056
- What was implemented: Character management UI in the project settings modal
- Files changed:
- src/lib/supabase/client.ts - browser client using createBrowserClient from @supabase/ssr
- src/lib/supabase/server.ts - server client for App Router with async cookies() API
- src/lib/supabase/middleware.ts - middleware helper with updateSession function
- src/lib/.gitkeep - removed (no longer needed)
- `src/components/editor/ProjectSettingsModal.tsx` - New modal component with Characters and Variables tabs; Characters tab has full CRUD (add, edit, delete with usage warnings), name uniqueness validation, color picker, inline forms
- `src/components/editor/Toolbar.tsx` - Added `onProjectSettings` prop and "Project Settings" button to the right side of the toolbar
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `characters` and `variables` state management, `showSettings` modal state, usage count helpers (`getCharacterUsageCount`, `getVariableUsageCount`), and ProjectSettingsModal rendering
- **Learnings for future iterations:**
- @supabase/ssr package provides createBrowserClient and createServerClient functions
- Server client requires async cookies() from next/headers in Next.js 16
- Middleware client returns both user object and supabaseResponse for route protection
- Cookie handling uses getAll/setAll pattern for proper session management
- setAll in server.ts wrapped in try/catch to handle Server Component limitations
- The ProjectSettingsModal receives `onCharactersChange` and `onVariablesChange` callbacks that directly set state in FlowchartEditor. When save is implemented, it should read from this state.
- The Variables tab is a read-only placeholder in US-056; US-057 will implement the full CRUD for variables using the same patterns (inline forms, validation, delete warnings).
- Modal pattern: fixed inset-0 z-50 with backdrop click to close, max-w-2xl for settings modals (larger than max-w-md used for simple dialogs).
- Character usage count checks dialogue nodes for `data.characterId`; variable usage count checks both variable nodes and edge conditions.
- The `randomHexColor()` utility picks from a curated list of 12 vibrant colors for character defaults.
- No browser testing tools are available; manual verification is needed.
---
## 2026-01-21 - US-005
- What was implemented: Protected routes middleware for authentication
## 2026-01-23 - US-057
- What was implemented: Variable management UI with full CRUD in the project settings modal Variables tab
- Files changed:
- middleware.ts - new file at project root for route protection
- `src/components/editor/ProjectSettingsModal.tsx` - Replaced placeholder VariablesTab with full implementation: add/edit/delete with inline forms, type dropdown (numeric/string/boolean), type-adaptive initial value input (number input for numeric, text for string, select for boolean), name uniqueness validation, delete warnings with usage count, colored type badges
- **Learnings for future iterations:**
- Next.js middleware.ts must be at project root (not in src/)
- updateSession helper from lib/supabase/middleware.ts returns { user, supabaseResponse }
- Use startsWith() for route matching to handle nested routes (e.g., /editor/*)
- Matcher config excludes static files and images to avoid unnecessary middleware calls
- Clone nextUrl before modifying pathname for redirects
- The VariableForm uses a `handleTypeChange` helper that resets the initial value to the type's default when the type changes, preventing invalid state (e.g., "hello" as a numeric value)
- Initial values are stored as strings in form state and parsed to the correct type (number/string/boolean) on save via `parseInitialValue()`
- Type badges use distinct colors: blue for numeric, green for string, purple for boolean - making variable types instantly recognizable in the list
- 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-21 - US-006
- What was implemented: Login page with email/password authentication
## 2026-01-23 - US-059
- What was implemented: Variable node variable dropdown using Combobox component, replacing the free-text input
- Files changed:
- src/app/login/page.tsx - new file with login form and Supabase auth
- `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:**
- Auth pages use 'use client' directive since they need useState and form handling
- Use createClient() from lib/supabase/client.ts for browser-side auth operations
- supabase.auth.signInWithPassword returns { error } object for handling failures
- useRouter from next/navigation for programmatic redirects after auth
- Error state displayed in red alert box with dark mode support
- Loading state disables submit button and shows "Signing in..." text
- TailwindCSS dark mode uses dark: prefix (e.g., dark:bg-zinc-950)
- 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-21 - US-007
- What was implemented: Sign up page for invite-only account setup
## 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/app/signup/page.tsx - new file with signup form and Supabase auth
- `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:**
- Supabase invite tokens come via URL hash fragment (window.location.hash)
- Parse hash with URLSearchParams after removing leading '#'
- Check for type=invite or type=signup to detect invite flow
- Use setSession() with access_token and refresh_token to establish session from invite link
- For invited users, update password with updateUser() then create profile with upsert()
- Use upsert() instead of insert() for profiles to handle edge cases
- Validate password confirmation before submission (passwords match check)
- display_name defaults to email prefix (split('@')[0])
- 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-21 - US-008
- What was implemented: Logout functionality component
## 2026-01-23 - US-061
- What was implemented: Choice option condition variable dropdown using OptionConditionEditor component with Combobox
- Files changed:
- src/components/LogoutButton.tsx - new client component with signOut and redirect
- src/components/.gitkeep - removed (no longer needed)
- `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:**
- LogoutButton is a reusable component that will be used in the navbar (US-011)
- Component uses 'use client' directive for client-side auth operations
- Loading state prevents double-clicks during signOut
- Styled with neutral zinc colors to work as a secondary button in navbars
- 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-21 - US-009
- What was implemented: Password reset - forgot password page
## 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/forgot-password/page.tsx - new file with forgot password form and email reset
- `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:**
- resetPasswordForEmail requires redirectTo option to specify where user lands after clicking reset link
- Use `window.location.origin` to get the current site URL for redirectTo
- Page shows different UI after success (conditional rendering with success state)
- Use &apos; for apostrophe in JSX to avoid HTML entity issues
- Follow same styling pattern as login page for consistency across auth pages
- 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-21 - US-010
- What was implemented: Password reset - set new password page
## 2026-01-23 - US-063
- What was implemented: Import characters/variables from another project via modal in project settings
- Files changed:
- src/app/reset-password/page.tsx - new file with password reset form
- src/app/login/page.tsx - updated to show success message from password reset
- `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:**
- Supabase recovery tokens come via URL hash fragment with type=recovery
- Use setSession() with access_token and refresh_token from hash to establish recovery session
- Show loading state while verifying token validity (tokenValid === null)
- Show error state with link to request new reset if token is invalid
- After password update, sign out the user and redirect to login with success message
- Use query param (message=password_reset_success) to pass success state between pages
- Login page uses useSearchParams to read and display success messages
- Success messages styled with green background (bg-green-50)
---
## 2026-01-21 - US-011
- What was implemented: Dashboard layout with navbar component
- Files changed:
- src/app/dashboard/layout.tsx - new file with dashboard layout wrapper
- src/components/Navbar.tsx - new reusable navbar component
- **Learnings for future iterations:**
- Dashboard layout is a server component that fetches user data via createClient() from lib/supabase/server.ts
- Navbar accepts userEmail prop to display current user
- Layout wraps children with consistent max-w-7xl container and padding
- Navbar uses Link component to allow clicking app title to go back to dashboard
- Navbar has border-b styling with dark mode support for visual separation
- Use gap-4 for spacing between navbar items (user email and logout button)
---
## 2026-01-21 - US-012
- What was implemented: Dashboard page listing user projects
- Files changed:
- src/app/dashboard/page.tsx - new file with project listing, cards, and empty state
- **Learnings for future iterations:**
- Dashboard page is a server component that fetches projects directly from Supabase
- Use .eq('user_id', user.id) for RLS-backed queries (though RLS also enforces this)
- Order by updated_at descending to show most recent projects first
- formatDate() helper with toLocaleDateString for human-readable dates
- Project cards use Link component for navigation to /editor/[projectId]
- Empty state uses dashed border (border-dashed) with centered content and icon
- Hover effects on cards: border-blue-300, shadow-md, and text color change on title
- Error state displayed if Supabase query fails
---
## 2026-01-21 - US-013
- What was implemented: Create new project functionality
- Files changed:
- src/components/NewProjectButton.tsx - new client component with modal dialog
- src/app/dashboard/page.tsx - added NewProjectButton to header area
- src/app/signup/page.tsx - fixed lint error (setState in effect) by initializing email from searchParams
- **Learnings for future iterations:**
- Modal dialogs use fixed positioning with backdrop (bg-black/50) for overlay effect
- Form submission uses Supabase insert with .select('id').single() to get the new record ID
- Initialize flowchart_data with { nodes: [], edges: [] } for new projects
- router.push() for programmatic navigation after successful creation
- autoFocus on input for better UX when modal opens
- Prevent modal close while loading (check isLoading before calling handleClose)
- ESLint rule react-hooks/set-state-in-effect warns against synchronous setState in useEffect
- Initialize state from searchParams directly in useState() instead of setting in useEffect
---
## 2026-01-21 - US-014
- What was implemented: Delete project functionality with confirmation dialog and toast
- Files changed:
- src/components/ProjectCard.tsx - new client component replacing Link, with delete button and confirmation dialog
- src/components/ProjectList.tsx - new wrapper component to manage project list state and toast notifications
- src/components/Toast.tsx - new reusable toast notification component
- src/app/dashboard/page.tsx - updated to use ProjectList instead of inline rendering
- **Learnings for future iterations:**
- To enable client-side state updates (like removing items), extract list rendering from server components into client components
- ProjectList accepts initialProjects from server and manages state locally for immediate UI updates
- Use onDelete callback pattern to propagate deletion events from child (ProjectCard) to parent (ProjectList)
- Delete button uses e.stopPropagation() to prevent card click navigation when clicking delete
- Confirmation dialogs should disable close/cancel while action is in progress (isDeleting check)
- Toast component uses useCallback for handlers and auto-dismiss with setTimeout
- Toast animations can use TailwindCSS animate-in utilities (fade-in, slide-in-from-bottom-4)
- Delete icon appears on hover using group-hover:opacity-100 with parent group class
---
## 2026-01-21 - US-015
- What was implemented: Rename project functionality
- Files changed:
- src/components/ProjectCard.tsx - added rename button, modal dialog, and Supabase update logic
- src/components/ProjectList.tsx - added handleRename callback and toast notification
- **Learnings for future iterations:**
- Multiple action buttons on a card can be grouped in a flex container with gap-1
- Rename modal follows same pattern as delete dialog: fixed positioning, backdrop, form
- Use onKeyDown to handle Enter key for quick form submission
- Reset form state (newName, error) when opening modal to handle edge cases
- Check if name is unchanged before making API call to avoid unnecessary requests
- Trim whitespace from input value before validation and submission
- handleRename callback updates project name in state using map() to preserve list order
---
## 2026-01-21 - US-016
- What was implemented: Admin invite user functionality
- Files changed:
- src/app/admin/invite/page.tsx - new admin-only page with access check (redirects non-admins)
- src/app/admin/invite/InviteForm.tsx - client component with invite form and state management
- src/app/admin/invite/actions.ts - server action using service role key to call inviteUserByEmail
- src/components/Navbar.tsx - added isAdmin prop and "Invite User" link (visible only to admins)
- src/app/dashboard/layout.tsx - fetches profile.is_admin and passes it to Navbar
- .env.example - added SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SITE_URL
- **Learnings for future iterations:**
- Admin operations require SUPABASE_SERVICE_ROLE_KEY (server-side only, not NEXT_PUBLIC_*)
- Use createClient from @supabase/supabase-js directly for admin client (not @supabase/ssr)
- Admin client needs auth config: { autoRefreshToken: false, persistSession: false }
- inviteUserByEmail requires redirectTo option for the signup link in email
- Server actions ('use server') can access private env vars safely
- Admin check should happen both in server component (redirect) and server action (double check)
- Admin page uses its own layout (not dashboard layout) to have custom styling
---
## 2026-01-21 - US-017
- What was implemented: Editor page with React Flow canvas
- Files changed:
- package.json - added reactflow dependency
- src/app/editor/[projectId]/page.tsx - new server component that fetches project from Supabase, handles auth/not found, renders header with back link
- src/app/editor/[projectId]/FlowchartEditor.tsx - new client component with React Flow canvas, Background component, type converters for nodes/edges
- src/app/editor/[projectId]/loading.tsx - new loading state component with spinner
- **Learnings for future iterations:**
- React Flow requires 'use client' directive since it uses browser APIs
- Import 'reactflow/dist/style.css' for default React Flow styling
- Use useNodesState and useEdgesState hooks for managing nodes/edges state
- Convert app types (FlowchartNode, FlowchartEdge) to React Flow types with helper functions
- Next.js dynamic route params come as Promise in App Router 16+ (need to await params)
- Use notFound() from next/navigation for 404 responses
- React Flow canvas needs parent container with explicit height (h-full, h-screen)
- Background component accepts variant (Dots, Lines, Cross) and gap/size props
- Loading page (loading.tsx) provides automatic loading UI for async server components
---
## 2026-01-21 - US-018
- What was implemented: Canvas pan and zoom controls
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added Controls import and component
- **Learnings for future iterations:**
- React Flow Controls component provides zoom +/-, fitView, and lock buttons out of the box
- Use position="bottom-right" prop to position controls in bottom-right corner
- Pan (click-and-drag) and zoom (mouse wheel) are React Flow defaults, no extra config needed
---
## 2026-01-21 - US-019
- What was implemented: Editor toolbar with add/save/export/import buttons
- Files changed:
- src/components/editor/Toolbar.tsx - new toolbar component with styled buttons
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated toolbar with placeholder handlers
- **Learnings for future iterations:**
- Toolbar component accepts callback props for actions (onAddDialogue, onSave, etc.)
- Node type buttons use color coding: blue (Dialogue), green (Choice), orange (Variable)
- Action buttons (Save, Export, Import) use neutral bordered styling
- FlowchartEditor now uses flex-col layout to stack toolbar above canvas
- Placeholder handlers with TODO comments help track future implementation work
---
## 2026-01-21 - US-020
- What was implemented: Custom DialogueNode component for displaying/editing character dialogue
- Files changed:
- src/components/editor/nodes/DialogueNode.tsx - new custom node component with editable speaker and text fields
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered DialogueNode as custom node type
- **Learnings for future iterations:**
- Custom React Flow nodes use NodeProps<T> for typing, where T is the data shape
- Use useReactFlow() hook to get setNodes for updating node data from within the node component
- Handle components need Position enum (Position.Top, Position.Bottom) for positioning
- Custom handles can be styled with className and TailwindCSS, use ! prefix to override defaults (e.g., !h-3, !w-3)
- Node types must be registered in a nodeTypes object and passed to ReactFlow component
- Memoize nodeTypes with useMemo to prevent unnecessary re-renders
- Custom node components go in src/components/editor/nodes/ directory
---
## 2026-01-21 - US-021
- What was implemented: Add dialogue node from toolbar functionality
- Files changed:
- package.json - added nanoid dependency for unique ID generation
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddDialogue to create new dialogue nodes at viewport center
- **Learnings for future iterations:**
- useReactFlow() hook requires ReactFlowProvider wrapper, so split component into inner component and outer wrapper
- getViewport() returns { x, y, zoom } representing the current pan/zoom state
- Calculate viewport center: centerX = (-viewport.x + halfWidth) / viewport.zoom
- nanoid v5+ generates unique IDs synchronously with no dependencies
- Node creation pattern: create Node object with { id, type, position, data }, then add to state via setNodes
- React Flow nodes are draggable by default, no extra configuration needed
---
## 2026-01-21 - US-022
- What was implemented: Custom ChoiceNode component for displaying branching decisions
- Files changed:
- src/components/editor/nodes/ChoiceNode.tsx - new custom node component with green styling, editable prompt, and dynamic option handles
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered ChoiceNode as custom node type
- **Learnings for future iterations:**
- ChoiceNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
- Dynamic handles positioned using style={{ left: `${((index + 1) / (options.length + 1)) * 100}%` }} for even spacing
- Handle id format for options: 'option-0', 'option-1', etc. (matching the index)
- Each option needs a unique id (string) and label (string) per the ChoiceOption type
- updateOptionLabel callback pattern: find option by id, map over options array to update matching one
---
## 2026-01-21 - US-023
- What was implemented: Add choice node from toolbar functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddChoice to create new choice nodes at viewport center
- **Learnings for future iterations:**
- handleAddChoice follows same pattern as handleAddDialogue: get viewport center, create node with nanoid, add to state
- Choice nodes must be initialized with 2 options (each with unique id via nanoid and empty label)
- Node data structure for choice: { prompt: '', options: [{ id, label }, { id, label }] }
- React Flow nodes are draggable by default after being added to state
---
## 2026-01-21 - US-024
- What was implemented: Add/remove choice options functionality (2-6 options supported)
- Files changed:
- src/components/editor/nodes/ChoiceNode.tsx - added addOption and removeOption callbacks, '+' button to add options, 'x' button per option to remove
- **Learnings for future iterations:**
- Define MIN_OPTIONS and MAX_OPTIONS constants for clear limits
- Use disabled prop on buttons to enforce min/max constraints with appropriate styling (opacity-30, cursor-not-allowed)
- Remove button uses × character for simple cross icon
- Add button styled with border-dashed for visual distinction from action buttons
- Handles update dynamically via React Flow re-render when options array changes
---
## 2026-01-21 - US-025
- What was implemented: Custom VariableNode component for setting/modifying story variables
- Files changed:
- src/components/editor/nodes/VariableNode.tsx - new custom node component with orange styling, editable variable name, operation dropdown, and numeric value input
- src/app/editor/[projectId]/FlowchartEditor.tsx - imported and registered VariableNode in nodeTypes
- **Learnings for future iterations:**
- VariableNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
- Use parseFloat() with fallback to 0 for number input handling: `parseFloat(e.target.value) || 0`
- Operation dropdown uses select element with options for 'set', 'add', 'subtract'
- Type assertion needed for select value: `e.target.value as 'set' | 'add' | 'subtract'`
- Use `??` (nullish coalescing) for number defaults instead of `||` to allow 0 values: `data.value ?? 0`
---
## 2026-01-21 - US-026
- What was implemented: Add variable node from toolbar functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddVariable to create new variable nodes at viewport center
- **Learnings for future iterations:**
- handleAddVariable follows same pattern as handleAddDialogue and handleAddChoice: get viewport center, create node with nanoid, add to state
- Variable nodes initialized with { variableName: '', operation: 'set', value: 0 }
- All add node handlers share the same pattern and use the getViewportCenter helper
---
## 2026-01-21 - US-027
- What was implemented: Connect nodes with edges including arrow markers and smooth styling
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added MarkerType import, updated onConnect to create edges with smoothstep type and arrow markers, updated toReactFlowEdges to apply same styling to loaded edges
- **Learnings for future iterations:**
- Use `type: 'smoothstep'` for cleaner edge curves instead of default bezier
- Use `markerEnd: { type: MarkerType.ArrowClosed }` to add directional arrows to edges
- Connection type has nullable source/target, but Edge requires non-null strings - guard with early return
- Apply consistent edge styling in both onConnect (new edges) and toReactFlowEdges (loaded edges)
- Generate unique edge IDs with nanoid in onConnect callback
---
## 2026-01-21 - US-028
- What was implemented: Select and delete nodes functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added deleteKeyCode prop to enable Delete/Backspace key deletion
- **Learnings for future iterations:**
- React Flow has built-in node selection via clicking - no extra configuration needed
- Use `deleteKeyCode={['Delete', 'Backspace']}` prop to enable keyboard deletion
- React Flow automatically removes connected edges when a node is deleted (no manual cleanup needed)
- The useNodesState/useEdgesState hooks with onNodesChange/onEdgesChange handle all deletion state updates
- No explicit onNodesDelete callback is needed - the onNodesChange handler covers deletion events
---
## 2026-01-21 - US-029
- What was implemented: Select and delete edges functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added onEdgesDelete callback
- **Learnings for future iterations:**
- React Flow 11 edges are clickable and selectable by default (interactionWidth renders invisible interaction area)
- The `deleteKeyCode` prop works for both nodes and edges - same configuration covers both
- onEdgesDelete is optional if you just need state management (onEdgesChange handles removal events)
- 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
- 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.
---

View File

@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
@ -22,11 +22,16 @@ 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 type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
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
@ -55,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(
() => ({
@ -69,11 +214,73 @@ 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[]>(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) => {
return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length
},
[nodes]
)
const getVariableUsageCount = useCallback(
(variableId: string) => {
const nodeCount = nodes.filter(
(n) => n.type === 'variable' && n.data?.variableId === variableId
).length
const edgeCount = edges.filter(
(e) => e.data?.condition?.variableId === variableId
).length
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]
)
const onConnect = useCallback(
@ -168,33 +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}
/>
<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>
<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 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>
</div>
</EditorProvider>
)
}

View File

@ -31,10 +31,17 @@ export default async function EditorPage({ params }: PageProps) {
notFound()
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
const rawData = project.flowchart_data || {}
const flowchartData: FlowchartData = {
nodes: rawData.nodes || [],
edges: rawData.edges || [],
characters: rawData.characters || [],
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">
@ -67,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
<FlowchartEditor
projectId={project.id}
initialData={flowchartData}
needsMigration={needsMigration}
/>
</div>
</div>

View File

@ -0,0 +1,263 @@
'use client'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
export type ComboboxItem = {
id: string
label: string
color?: string
badge?: string
}
type ComboboxProps = {
items: ComboboxItem[]
value: string | undefined
onChange: (id: string) => void
placeholder?: string
onAddNew?: () => void
}
export default function Combobox({
items,
value,
onChange,
placeholder = 'Select...',
onAddNew,
}: ComboboxProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(0)
const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const selectedItem = useMemo(
() => items.find((item) => item.id === value),
[items, value]
)
const filteredItems = useMemo(
() =>
items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
),
[items, search]
)
const totalOptions = filteredItems.length + (onAddNew ? 1 : 0)
const updateDropdownPosition = useCallback(() => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
setDropdownPosition(spaceBelow < 200 && spaceAbove > spaceBelow ? 'above' : 'below')
}, [])
const open = useCallback(() => {
setIsOpen(true)
setSearch('')
setHighlightedIndex(0)
updateDropdownPosition()
}, [updateDropdownPosition])
const close = useCallback(() => {
setIsOpen(false)
setSearch('')
}, [])
const selectItem = useCallback(
(id: string) => {
onChange(id)
close()
},
[onChange, close]
)
// Close on outside click
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
close()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, close])
// Scroll highlighted item into view
useEffect(() => {
if (!isOpen || !listRef.current) return
const items = listRef.current.querySelectorAll('[data-combobox-item]')
const highlighted = items[highlightedIndex]
if (highlighted) {
highlighted.scrollIntoView({ block: 'nearest' })
}
}, [highlightedIndex, isOpen])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
e.preventDefault()
open()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((prev) => (prev + 1) % totalOptions)
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions)
break
case 'Enter':
e.preventDefault()
if (highlightedIndex < filteredItems.length) {
selectItem(filteredItems[highlightedIndex].id)
} else if (onAddNew && highlightedIndex === filteredItems.length) {
onAddNew()
close()
}
break
case 'Escape':
e.preventDefault()
close()
break
}
},
[isOpen, open, close, highlightedIndex, filteredItems, totalOptions, selectItem, onAddNew]
)
return (
<div ref={containerRef} className="relative w-full">
<div
className="flex w-full cursor-pointer items-center rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-white"
onClick={() => {
if (isOpen) {
close()
} else {
open()
setTimeout(() => inputRef.current?.focus(), 0)
}
}}
>
{isOpen ? (
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value)
setHighlightedIndex(0)
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full bg-transparent outline-none placeholder-zinc-400 dark:placeholder-zinc-500"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={selectedItem ? '' : 'text-zinc-400 dark:text-zinc-500'}>
{selectedItem ? (
<span className="flex items-center gap-1.5">
{selectedItem.color && (
<span
className="inline-block h-3 w-3 rounded-full border border-zinc-300 dark:border-zinc-600"
style={{ backgroundColor: selectedItem.color }}
/>
)}
{selectedItem.badge && (
<span className="rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
{selectedItem.badge}
</span>
)}
{selectedItem.label}
</span>
) : (
placeholder
)}
</span>
)}
<svg
className={`ml-auto h-4 w-4 shrink-0 text-zinc-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && (
<ul
ref={listRef}
className={`absolute z-50 max-h-48 w-full overflow-auto rounded border border-zinc-300 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-800 ${
dropdownPosition === 'above' ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
>
{filteredItems.length === 0 && !onAddNew && (
<li className="px-2 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
No results found
</li>
)}
{filteredItems.map((item, index) => (
<li
key={item.id}
data-combobox-item
className={`flex cursor-pointer items-center gap-1.5 px-2 py-1.5 text-sm ${
highlightedIndex === index
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700'
} ${item.id === value ? 'font-medium' : ''}`}
onClick={() => selectItem(item.id)}
onMouseEnter={() => setHighlightedIndex(index)}
>
{item.color && (
<span
className="inline-block h-3 w-3 shrink-0 rounded-full border border-zinc-300 dark:border-zinc-600"
style={{ backgroundColor: item.color }}
/>
)}
{item.badge && (
<span className="shrink-0 rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
{item.badge}
</span>
)}
<span className="truncate">{item.label}</span>
</li>
))}
{onAddNew && (
<li
data-combobox-item
className={`flex cursor-pointer items-center gap-1.5 border-t border-zinc-200 px-2 py-1.5 text-sm dark:border-zinc-700 ${
highlightedIndex === filteredItems.length
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
: 'text-blue-600 hover:bg-zinc-100 dark:text-blue-400 dark:hover:bg-zinc-700'
}`}
onClick={() => {
onAddNew()
close()
}}
onMouseEnter={() => setHighlightedIndex(filteredItems.length)}
>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span>Add new...</span>
</li>
)}
</ul>
)}
</div>
)
}

View File

@ -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>
)
}

View File

@ -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)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,763 @@
'use client'
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
onVariablesChange: (variables: Variable[]) => void
onClose: () => void
getCharacterUsageCount: (characterId: string) => number
getVariableUsageCount: (variableId: string) => number
}
function randomHexColor(): string {
const colors = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
'#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7',
'#ec4899', '#f43f5e',
]
return colors[Math.floor(Math.random() * colors.length)]
}
export default function ProjectSettingsModal({
projectId,
characters,
variables,
onCharactersChange,
onVariablesChange,
onClose,
getCharacterUsageCount,
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">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative flex h-[80vh] w-full max-w-2xl 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">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Project Settings
</h2>
<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>
{/* Tabs */}
<div className="flex border-b border-zinc-200 px-6 dark:border-zinc-700">
<button
onClick={() => setActiveTab('characters')}
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === 'characters'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
}`}
>
Characters
</button>
<button
onClick={() => setActiveTab('variables')}
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === 'variables'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
}`}
>
Variables
</button>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{activeTab === 'characters' && (
<CharactersTab
characters={characters}
onChange={onCharactersChange}
getUsageCount={getCharacterUsageCount}
onImport={() => setImportModal({ open: true, mode: 'characters' })}
/>
)}
{activeTab === 'variables' && (
<VariablesTab
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>
)
}
// Characters Tab
type CharactersTabProps = {
characters: Character[]
onChange: (characters: Character[]) => void
getUsageCount: (characterId: string) => number
onImport: () => void
}
type CharacterFormData = {
name: string
color: string
description: string
}
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: '' })
const [formError, setFormError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const resetForm = () => {
setFormData({ name: '', color: randomHexColor(), description: '' })
setFormError(null)
}
const validateName = (name: string, excludeId?: string): boolean => {
if (!name.trim()) {
setFormError('Name is required')
return false
}
const duplicate = characters.find(
(c) => c.name.toLowerCase() === name.trim().toLowerCase() && c.id !== excludeId
)
if (duplicate) {
setFormError('A character with this name already exists')
return false
}
setFormError(null)
return true
}
const handleAdd = () => {
if (!validateName(formData.name)) return
const newCharacter: Character = {
id: nanoid(),
name: formData.name.trim(),
color: formData.color,
description: formData.description.trim() || undefined,
}
onChange([...characters, newCharacter])
setIsAdding(false)
resetForm()
}
const handleEdit = (character: Character) => {
setEditingId(character.id)
setFormData({
name: character.name,
color: character.color,
description: character.description || '',
})
setFormError(null)
setIsAdding(false)
}
const handleSaveEdit = () => {
if (!editingId) return
if (!validateName(formData.name, editingId)) return
onChange(
characters.map((c) =>
c.id === editingId
? { ...c, name: formData.name.trim(), color: formData.color, description: formData.description.trim() || undefined }
: c
)
)
setEditingId(null)
resetForm()
}
const handleDelete = (id: string) => {
const usageCount = getUsageCount(id)
if (usageCount > 0 && deleteConfirm !== id) {
setDeleteConfirm(id)
return
}
onChange(characters.filter((c) => c.id !== id))
setDeleteConfirm(null)
}
const handleCancelForm = () => {
setIsAdding(false)
setEditingId(null)
resetForm()
}
return (
<div>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Define characters that can be referenced in dialogue nodes.
</p>
{!isAdding && !editingId && (
<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>
{/* Character List */}
<div className="space-y-2">
{characters.map((character) => (
<div key={character.id}>
{editingId === character.id ? (
<CharacterForm
formData={formData}
formError={formError}
onChange={setFormData}
onSave={handleSaveEdit}
onCancel={handleCancelForm}
saveLabel="Save"
/>
) : (
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: character.color }}
/>
<div>
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
{character.name}
</span>
{character.description && (
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{character.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{deleteConfirm === character.id && (
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
Used in {getUsageCount(character.id)} node(s). Delete anyway?
</span>
)}
<button
onClick={() => handleEdit(character)}
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Edit
</button>
<button
onClick={() => handleDelete(character.id)}
className={`rounded px-2 py-1 text-sm ${
deleteConfirm === character.id
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
}`}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
{characters.length === 0 && !isAdding && (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
No characters defined yet. Click &quot;Add Character&quot; to create one.
</p>
)}
</div>
{/* Add Form */}
{isAdding && (
<div className="mt-4">
<CharacterForm
formData={formData}
formError={formError}
onChange={setFormData}
onSave={handleAdd}
onCancel={handleCancelForm}
saveLabel="Add"
/>
</div>
)}
</div>
)
}
// Character Form
type CharacterFormProps = {
formData: CharacterFormData
formError: string | null
onChange: (data: CharacterFormData) => void
onSave: () => void
onCancel: () => void
saveLabel: string
}
function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: CharacterFormProps) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Color
</label>
<input
type="color"
value={formData.color}
onChange={(e) => onChange({ ...formData, color: e.target.value })}
className="h-8 w-8 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
/>
</div>
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => onChange({ ...formData, name: e.target.value })}
placeholder="Character name"
autoFocus
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Description
</label>
<input
type="text"
value={formData.description}
onChange={(e) => onChange({ ...formData, description: e.target.value })}
placeholder="Optional description"
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
</div>
{formError && (
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
)}
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="rounded-md 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"
>
Cancel
</button>
<button
onClick={onSave}
className="rounded-md 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"
>
{saveLabel}
</button>
</div>
</div>
</div>
)
}
// Variables Tab
type VariablesTabProps = {
variables: Variable[]
onChange: (variables: Variable[]) => void
getUsageCount: (variableId: string) => number
onImport: () => void
}
type VariableType = 'numeric' | 'string' | 'boolean'
type VariableFormData = {
name: string
type: VariableType
initialValue: string
description: string
}
const defaultInitialValues: Record<VariableType, string> = {
numeric: '0',
string: '',
boolean: 'false',
}
function parseInitialValue(type: VariableType, raw: string): number | string | boolean {
switch (type) {
case 'numeric':
return Number(raw) || 0
case 'boolean':
return raw === 'true'
case 'string':
return raw
}
}
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: '' })
const [formError, setFormError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const resetForm = () => {
setFormData({ name: '', type: 'numeric', initialValue: '0', description: '' })
setFormError(null)
}
const validateName = (name: string, excludeId?: string): boolean => {
if (!name.trim()) {
setFormError('Name is required')
return false
}
const duplicate = variables.find(
(v) => v.name.toLowerCase() === name.trim().toLowerCase() && v.id !== excludeId
)
if (duplicate) {
setFormError('A variable with this name already exists')
return false
}
setFormError(null)
return true
}
const handleAdd = () => {
if (!validateName(formData.name)) return
const newVariable: Variable = {
id: nanoid(),
name: formData.name.trim(),
type: formData.type,
initialValue: parseInitialValue(formData.type, formData.initialValue),
description: formData.description.trim() || undefined,
}
onChange([...variables, newVariable])
setIsAdding(false)
resetForm()
}
const handleEdit = (variable: Variable) => {
setEditingId(variable.id)
setFormData({
name: variable.name,
type: variable.type,
initialValue: String(variable.initialValue),
description: variable.description || '',
})
setFormError(null)
setIsAdding(false)
}
const handleSaveEdit = () => {
if (!editingId) return
if (!validateName(formData.name, editingId)) return
onChange(
variables.map((v) =>
v.id === editingId
? {
...v,
name: formData.name.trim(),
type: formData.type,
initialValue: parseInitialValue(formData.type, formData.initialValue),
description: formData.description.trim() || undefined,
}
: v
)
)
setEditingId(null)
resetForm()
}
const handleDelete = (id: string) => {
const usageCount = getUsageCount(id)
if (usageCount > 0 && deleteConfirm !== id) {
setDeleteConfirm(id)
return
}
onChange(variables.filter((v) => v.id !== id))
setDeleteConfirm(null)
}
const handleCancelForm = () => {
setIsAdding(false)
setEditingId(null)
resetForm()
}
return (
<div>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Define variables that can be referenced in variable nodes and edge conditions.
</p>
{!isAdding && !editingId && (
<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>
{/* Variable List */}
<div className="space-y-2">
{variables.map((variable) => (
<div key={variable.id}>
{editingId === variable.id ? (
<VariableForm
formData={formData}
formError={formError}
onChange={setFormData}
onSave={handleSaveEdit}
onCancel={handleCancelForm}
saveLabel="Save"
/>
) : (
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
<div className="flex items-center gap-3">
<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>
<div>
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
{variable.name}
</span>
{variable.description && (
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{variable.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Initial: {String(variable.initialValue)}
</span>
<div className="flex items-center gap-2">
{deleteConfirm === variable.id && (
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
Used in {getUsageCount(variable.id)} node(s). Delete anyway?
</span>
)}
<button
onClick={() => handleEdit(variable)}
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Edit
</button>
<button
onClick={() => handleDelete(variable.id)}
className={`rounded px-2 py-1 text-sm ${
deleteConfirm === variable.id
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
}`}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
))}
{variables.length === 0 && !isAdding && (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
No variables defined yet. Click &quot;Add Variable&quot; to create one.
</p>
)}
</div>
{/* Add Form */}
{isAdding && (
<div className="mt-4">
<VariableForm
formData={formData}
formError={formError}
onChange={setFormData}
onSave={handleAdd}
onCancel={handleCancelForm}
saveLabel="Add"
/>
</div>
)}
</div>
)
}
// Variable Form
type VariableFormProps = {
formData: VariableFormData
formError: string | null
onChange: (data: VariableFormData) => void
onSave: () => void
onCancel: () => void
saveLabel: string
}
function VariableForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: VariableFormProps) {
const handleTypeChange = (newType: VariableType) => {
onChange({ ...formData, type: newType, initialValue: defaultInitialValues[newType] })
}
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => onChange({ ...formData, name: e.target.value })}
placeholder="Variable name"
autoFocus
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Type *
</label>
<select
value={formData.type}
onChange={(e) => handleTypeChange(e.target.value as VariableType)}
className="block rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
>
<option value="numeric">Numeric</option>
<option value="string">String</option>
<option value="boolean">Boolean</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Initial Value *
</label>
{formData.type === 'boolean' ? (
<select
value={formData.initialValue}
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
>
<option value="false">false</option>
<option value="true">true</option>
</select>
) : formData.type === 'numeric' ? (
<input
type="number"
value={formData.initialValue}
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
) : (
<input
type="text"
value={formData.initialValue}
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
placeholder="Initial value"
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Description
</label>
<input
type="text"
value={formData.description}
onChange={(e) => onChange({ ...formData, description: e.target.value })}
placeholder="Optional description"
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
</div>
{formError && (
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
)}
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="rounded-md 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"
>
Cancel
</button>
<button
onClick={onSave}
className="rounded-md 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"
>
{saveLabel}
</button>
</div>
</div>
</div>
)
}

View File

@ -7,6 +7,7 @@ type ToolbarProps = {
onSave: () => void
onExport: () => void
onImport: () => void
onProjectSettings: () => void
}
export default function Toolbar({
@ -16,6 +17,7 @@ export default function Toolbar({
onSave,
onExport,
onImport,
onProjectSettings,
}: ToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
@ -44,6 +46,12 @@ export default function Toolbar({
</div>
<div className="flex items-center gap-2">
<button
onClick={onProjectSettings}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Project Settings
</button>
<button
onClick={onSave}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"

View File

@ -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)}
/>
)}
</>
)
}

View File

@ -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 || ''}

View File

@ -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

View File

@ -4,21 +4,50 @@ export type Position = {
y: number;
};
// Character type: represents a defined character in the project
export type Character = {
id: string;
name: string;
color: string; // hex color
description?: string;
};
// Variable type: represents a defined variable in the project
export type Variable = {
id: string;
name: string;
type: 'numeric' | 'string' | 'boolean';
initialValue: number | string | boolean;
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;
characterId?: string;
text: string;
};
export type DialogueNode = {
id: string;
type: 'dialogue';
position: Position;
data: {
speaker?: string;
text: string;
};
data: DialogueNodeData;
};
// Choice option type for ChoiceNode
export type ChoiceOption = {
id: string;
label: string;
condition?: Condition;
};
// ChoiceNode type: represents branching decisions
@ -33,27 +62,23 @@ export type ChoiceNode = {
};
// VariableNode type: represents variable operations
export type VariableNodeData = {
variableName: string;
variableId?: string;
operation: 'set' | 'add' | 'subtract';
value: number;
};
export type VariableNode = {
id: string;
type: 'variable';
position: Position;
data: {
variableName: string;
operation: 'set' | 'add' | 'subtract';
value: number;
};
data: VariableNodeData;
};
// 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;
@ -70,4 +95,6 @@ export type FlowchartEdge = {
export type FlowchartData = {
nodes: FlowchartNode[];
edges: FlowchartEdge[];
characters: Character[];
variables: Variable[];
};

View File

@ -0,0 +1,45 @@
-- Migration: Add characters and variables arrays to flowchart_data JSONB default
-- Part of Character/Variable Management feature (US-055)
--
-- The characters and variables arrays are stored within the flowchart_data JSONB column.
-- This migration updates the default value for new projects to include empty arrays.
-- Existing projects without these fields are handled at the application layer,
-- which defaults missing characters/variables to empty arrays on read.
-- =============================================================================
-- UPDATE DEFAULT VALUE FOR flowchart_data
-- =============================================================================
-- Update the default to include characters and variables arrays
ALTER TABLE projects
ALTER COLUMN flowchart_data
SET DEFAULT '{"nodes": [], "edges": [], "characters": [], "variables": []}'::jsonb;
-- =============================================================================
-- DOCUMENTATION: JSONB Structure
-- =============================================================================
-- The flowchart_data column now expects the following top-level structure:
--
-- {
-- "nodes": [...], -- Array of flowchart nodes
-- "edges": [...], -- Array of flowchart edges
-- "characters": [ -- Array of character definitions
-- {
-- "id": "string", -- Unique identifier (nanoid)
-- "name": "string", -- Character name (required, unique per project)
-- "color": "string", -- Hex color code (e.g., "#FF5733")
-- "description": "string" -- Optional description
-- }
-- ],
-- "variables": [ -- Array of variable definitions
-- {
-- "id": "string", -- Unique identifier (nanoid)
-- "name": "string", -- Variable name (required, unique per project)
-- "type": "string", -- One of: 'numeric', 'string', 'boolean'
-- "initialValue": "any", -- Initial value matching the type
-- "description": "string" -- Optional description
-- }
-- ]
-- }
--
-- Note: Existing projects that do not have characters/variables fields
-- are handled at the application layer, which defaults them to empty arrays.