diff --git a/prd.json b/prd.json index 196ae27..7a27321 100644 --- a/prd.json +++ b/prd.json @@ -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,733 +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": true, - "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": true, - "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": true, - "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": true, - "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": true, - "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": true, - "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": true, - "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": true, - "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": true, - "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": true, - "notes": "" - }, - { - "id": "US-040", - "title": "Conditionals on choice options", - "description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).", - "acceptanceCriteria": [ - "Each ChoiceOption can have optional condition (variableName, operator, value)", - "Update ChoiceNode UI to show 'Add condition' button per option", - "Condition editor modal for each option", - "Visual indicator (icon/badge) on options with conditions", - "Update TypeScript types: ChoiceOption gets optional condition field", - "Export includes per-option conditions in Ren'Py JSON", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 40, - "passes": true, - "notes": "Dependencies: US-018, US-019, US-025. Complexity: M" - }, - { - "id": "US-041", - "title": "Change password for logged-in user", - "description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.", - "acceptanceCriteria": [ - "Settings/profile page accessible from dashboard header", - "Form with: current password, new password, confirm new password fields", - "Calls Supabase updateUser with new password", - "Requires current password verification (re-authenticate)", - "Shows success/error messages", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 41, - "passes": true, - "notes": "Dependencies: US-004. Complexity: S" - }, - { - "id": "US-042", - "title": "Password reset modal on token arrival", - "description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.", - "acceptanceCriteria": [ - "Detect password reset token in URL (from Supabase email link)", - "Show modal/dialog automatically when token present", - "Modal has: new password, confirm password fields", - "Calls Supabase updateUser with token to complete reset", - "On success, close modal and redirect to login", - "On error, show error message", - "Typecheck passes", - "Verify in browser using dev-browser skill" - ], - "priority": 42, - "passes": true, - "notes": "Dependencies: US-006. Complexity: S" + "passes": false, + "notes": "Dependencies: US-052, US-048" } ] } diff --git a/progress.txt b/progress.txt index c5d0f32..d439bdb 100644 --- a/progress.txt +++ b/progress.txt @@ -27,414 +27,161 @@ - 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 ' 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 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 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 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. --- ## 2026-01-22 - US-030 diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 707cebc..51a8327 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,7 +1,11 @@ 'use client' +<<<<<<< HEAD import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' +======= +import React, { useCallback, useMemo, useState } from 'react' +>>>>>>> ralph/collaboration-and-character-variables import ReactFlow, { Background, BackgroundVariant, @@ -28,6 +32,7 @@ import { createClient } from '@/lib/supabase/client' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' +<<<<<<< HEAD import ConditionalEdge from '@/components/editor/edges/ConditionalEdge' import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import ConditionEditor from '@/components/editor/ConditionEditor' @@ -51,11 +56,19 @@ type ConditionEditorState = { edgeId: string condition?: Condition } | null +======= +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' +>>>>>>> ralph/collaboration-and-character-variables type FlowchartEditorProps = { projectId: string projectName: string initialData: FlowchartData + needsMigration?: boolean } // Convert our FlowchartNode type to React Flow Node type @@ -84,6 +97,7 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { })) } +<<<<<<< HEAD // Convert React Flow Node type back to our FlowchartNode type function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] { return nodes.map((node) => ({ @@ -390,11 +404,154 @@ function convertToRenpyFormat( projectName, exportedAt: new Date().toISOString(), sections, +======= +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() + 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() + 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() + 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() + 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`, +>>>>>>> ralph/collaboration-and-character-variables } } // Inner component that uses useReactFlow hook +<<<<<<< HEAD function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) { +======= +function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) { +>>>>>>> ralph/collaboration-and-character-variables // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -449,11 +606,73 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart return { showPrompt: false, savedDraft: null } }) + // 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(migratedData.characters) + const [variables, setVariables] = useState(migratedData.variables) + const [showSettings, setShowSettings] = useState(false) + const [selectedEdgeId, setSelectedEdgeId] = useState(null) + const [toastMessage, setToastMessage] = useState(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] ) // Track debounce timer @@ -809,400 +1028,89 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart console.log('Deleted edges:', deletedEdges.map((e) => e.id)) }, []) - // Context menu handlers - const closeContextMenu = useCallback(() => { - setContextMenu(null) + // Handle edge click to open condition editor + const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { + setSelectedEdgeId(edge.id) }, []) - // Handle right-click on canvas (pane) - const onPaneContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault() - setContextMenu({ - x: event.clientX, - y: event.clientY, - type: 'canvas', - }) - }, - [] - ) - - // Handle right-click on node - const onNodeContextMenu: NodeMouseHandler = useCallback( - (event, node) => { - event.preventDefault() - setContextMenu({ - x: event.clientX, - y: event.clientY, - type: 'node', - nodeId: node.id, - }) - }, - [] - ) - - // Handle right-click on edge - const onEdgeContextMenu: EdgeMouseHandler = useCallback( - (event, edge) => { - event.preventDefault() - setContextMenu({ - x: event.clientX, - y: event.clientY, - type: 'edge', - edgeId: edge.id, - }) - }, - [] - ) - - // Add node at specific position (for context menu) - const handleAddNodeAtPosition = useCallback( - (type: 'dialogue' | 'choice' | 'variable') => { - if (!contextMenu) return - - // Convert screen position to flow position - const position = screenToFlowPosition({ - x: contextMenu.x, - y: contextMenu.y, - }) - - let newNode: Node - - if (type === 'dialogue') { - newNode = { - id: nanoid(), - type: 'dialogue', - position, - data: { speaker: '', text: '' }, - } - } else if (type === 'choice') { - newNode = { - id: nanoid(), - type: 'choice', - position, - data: { - prompt: '', - options: [ - { id: nanoid(), label: '' }, - { id: nanoid(), label: '' }, - ], - }, - } - } else { - newNode = { - id: nanoid(), - type: 'variable', - position, - data: { - variableName: '', - operation: 'set', - value: 0, - }, - } - } - - setNodes((nodes) => [...nodes, newNode]) - }, - [contextMenu, screenToFlowPosition, setNodes] - ) - - // Delete selected node from context menu - const handleDeleteNode = useCallback(() => { - if (!contextMenu?.nodeId) return - setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId)) - }, [contextMenu, setNodes]) - - // Delete selected edge from context menu - const handleDeleteEdge = useCallback(() => { - if (!contextMenu?.edgeId) return - setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) - }, [contextMenu, setEdges]) - - // Open condition editor for an edge - const openConditionEditor = useCallback( - (edgeId: string) => { - const edge = edges.find((e) => e.id === edgeId) - if (!edge) return - setConditionEditor({ - edgeId, - condition: edge.data?.condition, - }) - }, - [edges] - ) - - // Add condition to edge (opens ConditionEditor modal) - const handleAddCondition = useCallback(() => { - if (!contextMenu?.edgeId) return - openConditionEditor(contextMenu.edgeId) - }, [contextMenu, openConditionEditor]) - - // Handle double-click on edge to open condition editor - const onEdgeDoubleClick = useCallback( - (_event: React.MouseEvent, edge: Edge) => { - openConditionEditor(edge.id) - }, - [openConditionEditor] - ) - - // Save condition to edge - const handleSaveCondition = useCallback( - (edgeId: string, condition: Condition) => { + // Handle condition change from ConditionEditor + const handleConditionChange = useCallback( + (edgeId: string, condition: Condition | undefined) => { setEdges((eds) => eds.map((edge) => edge.id === edgeId - ? { ...edge, data: { ...edge.data, condition } } + ? { ...edge, data: condition ? { condition } : undefined } : edge ) ) - setConditionEditor(null) }, [setEdges] ) - // Remove condition from edge - const handleRemoveCondition = useCallback( - (edgeId: string) => { - setEdges((eds) => - eds.map((edge) => { - if (edge.id !== edgeId) return edge - // Remove condition from data - const newData = { ...edge.data } - delete newData.condition - return { ...edge, data: newData } - }) - ) - setConditionEditor(null) - }, - [setEdges] + // Get the selected edge's condition data + const selectedEdge = useMemo( + () => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null), + [selectedEdgeId, edges] ) - // Close condition editor - const closeConditionEditor = useCallback(() => { - setConditionEditor(null) - }, []) - - // Router for navigation - const router = useRouter() - - // Handle back button click - show warning if dirty - const handleBackClick = useCallback(() => { - if (isDirty) { - setShowNavigationWarning(true) - } else { - router.push('/dashboard') - } - }, [isDirty, router]) - - // Confirm navigation (discard unsaved changes) - const handleConfirmNavigation = useCallback(() => { - setShowNavigationWarning(false) - router.push('/dashboard') - }, [router]) - - // Cancel navigation - const handleCancelNavigation = useCallback(() => { - setShowNavigationWarning(false) - }, []) - return ( -
- {/* Editor header with back button and project name */} -
-
- -

- {projectName} -

- {isDirty && ( - - (unsaved changes) - - )} + + +
-
- - -
- - - - + {showSettings && ( + setShowSettings(false)} + getCharacterUsageCount={getCharacterUsageCount} + getVariableUsageCount={getVariableUsageCount} + /> + )} + {selectedEdge && ( + setSelectedEdgeId(null)} + /> + )} + {toastMessage && ( + setToastMessage(null)} + /> + )}
- - {contextMenu && ( - handleAddNodeAtPosition('dialogue')} - onAddChoice={() => handleAddNodeAtPosition('choice')} - onAddVariable={() => handleAddNodeAtPosition('variable')} - onDelete={ - contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge - } - onAddCondition={handleAddCondition} - /> - )} - - {conditionEditor && ( - - )} - - {/* Draft restoration prompt */} - {draftState.showPrompt && ( -
-
-

- Unsaved Draft Found -

-

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

-
- - -
-
-
- )} - - {/* Import confirmation dialog */} - {importConfirmDialog && ( -
-
-

- Unsaved Changes -

-

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

-
- - -
-
-
- )} - - {/* Navigation warning dialog */} - {showNavigationWarning && ( -
-
-

- Unsaved Changes -

-

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

-
- - -
-
-
- )} - - {/* Hidden file input for import */} - - - {/* Toast notification */} - {toast && ( - setToast(null)} - action={toast.action} - /> - )} -
+ ) } diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 59ed19d..ca5abda 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -27,9 +27,25 @@ export default async function EditorPage({ params }: PageProps) { .single() if (error || !project) { - return ( -
-
+ notFound() + } + + 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 ( +
+
+
+
+ +
+
) } diff --git a/src/components/editor/Combobox.tsx b/src/components/editor/Combobox.tsx new file mode 100644 index 0000000..b84b7bb --- /dev/null +++ b/src/components/editor/Combobox.tsx @@ -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(null) + const inputRef = useRef(null) + const listRef = useRef(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 ( +
+
{ + if (isOpen) { + close() + } else { + open() + setTimeout(() => inputRef.current?.focus(), 0) + } + }} + > + {isOpen ? ( + { + 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()} + /> + ) : ( + + {selectedItem ? ( + + {selectedItem.color && ( + + )} + {selectedItem.badge && ( + + {selectedItem.badge} + + )} + {selectedItem.label} + + ) : ( + placeholder + )} + + )} + + + +
+ + {isOpen && ( +
    + {filteredItems.length === 0 && !onAddNew && ( +
  • + No results found +
  • + )} + + {filteredItems.map((item, index) => ( +
  • selectItem(item.id)} + onMouseEnter={() => setHighlightedIndex(index)} + > + {item.color && ( + + )} + {item.badge && ( + + {item.badge} + + )} + {item.label} +
  • + ))} + + {onAddNew && ( +
  • { + onAddNew() + close() + }} + onMouseEnter={() => setHighlightedIndex(filteredItems.length)} + > + + + + Add new... +
  • + )} +
+ )} +
+ ) +} diff --git a/src/components/editor/ConditionEditor.tsx b/src/components/editor/ConditionEditor.tsx index 045af92..b7259f2 100644 --- a/src/components/editor/ConditionEditor.tsx +++ b/src/components/editor/ConditionEditor.tsx @@ -1,164 +1,320 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +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 - onSave: (edgeId: string, condition: Condition) => void - onRemove: (edgeId: string) => void - onCancel: () => void + condition: Condition | undefined + onChange: (edgeId: string, condition: Condition | undefined) => void + onClose: () => void } -const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!='] - export default function ConditionEditor({ edgeId, condition, - onSave, - onRemove, - onCancel, + onChange, + onClose, }: ConditionEditorProps) { - const [variableName, setVariableName] = useState(condition?.variableName ?? '') - const [operator, setOperator] = useState(condition?.operator ?? '==') - const [value, setValue] = useState(condition?.value ?? 0) + const { variables, onAddVariable } = useEditorContext() - // Close on Escape key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onCancel() - } + 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 } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [onCancel]) + // string and boolean only support == and != + return [ + { value: '==', label: '==' }, + { value: '!=', label: '!=' }, + ] as const + }, [selectedVariable]) - const handleSave = useCallback(() => { - if (!variableName.trim()) return - onSave(edgeId, { - variableName: variableName.trim(), - operator, - value, + 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, }) - }, [edgeId, variableName, operator, value, onSave]) + setShowAddForm(false) + }, [newName, newType, onAddVariable, edgeId, onChange]) - const handleRemove = useCallback(() => { - onRemove(edgeId) - }, [edgeId, onRemove]) + const handleCancelNew = useCallback(() => { + setShowAddForm(false) + }, []) - const hasExistingCondition = !!condition + // Render value input based on variable type + const renderValueInput = () => { + const varType = selectedVariable?.type || 'numeric' + + if (varType === 'boolean') { + return ( + + ) + } + + if (varType === 'string') { + return ( + 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 ( + 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 ( -
-
e.stopPropagation()} - > -

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

+
+
+
+
+

+ Edge Condition +

+ +
-
- {/* Variable Name Input */} -
- - setVariableName(e.target.value)} - placeholder="e.g., score, health, affection" - autoFocus - className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500" + {/* Variable selector */} +
+ +
+
- - {/* Operator Dropdown */} -
- - -
- - {/* Value Number Input */} -
- - setValue(parseFloat(e.target.value) || 0)} - className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100" - /> -
- - {/* Preview */} - {variableName.trim() && ( -
- - Condition: {variableName.trim()} {operator} {value} - + {hasInvalidReference && ( +
+ Variable not found
)}
- {/* Action Buttons */} -
-
- {hasExistingCondition && ( - - )} + +
-
+ )} + + {/* Operator and value (shown when variable is selected) */} + {condition?.variableId && ( + <> +
+ + +
+ +
+ + {renderValueInput()} +
+ + )} + + {/* Actions */} +
+ {condition?.variableId ? ( - -
+ ) : ( +
+ )} +
diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx new file mode 100644 index 0000000..39a921d --- /dev/null +++ b/src/components/editor/EditorContext.tsx @@ -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({ + characters: [], + onAddCharacter: () => '', + variables: [], + onAddVariable: () => '', +}) + +export const EditorProvider = EditorContext.Provider + +export function useEditorContext() { + return useContext(EditorContext) +} diff --git a/src/components/editor/ImportFromProjectModal.tsx b/src/components/editor/ImportFromProjectModal.tsx new file mode 100644 index 0000000..e382b46 --- /dev/null +++ b/src/components/editor/ImportFromProjectModal.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedProjectId, setSelectedProjectId] = useState(null) + const [sourceCharacters, setSourceCharacters] = useState([]) + const [sourceVariables, setSourceVariables] = useState([]) + const [loadingSource, setLoadingSource] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [warnings, setWarnings] = useState([]) + + // 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 ( +
+