Merge branch 'ralph/collaboration-and-character-variables' into developing
This commit is contained in:
commit
7cb37ff940
790
prd.json
790
prd.json
|
|
@ -1,18 +1,19 @@
|
||||||
{
|
{
|
||||||
"project": "WebVNWrite",
|
"project": "WebVNWrite",
|
||||||
"branchName": "ralph/vn-flowchart-editor",
|
"branchName": "ralph/collaboration-and-character-variables",
|
||||||
"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",
|
"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": [
|
"userStories": [
|
||||||
{
|
{
|
||||||
"id": "US-001",
|
"id": "US-054",
|
||||||
"title": "Project scaffolding and configuration",
|
"title": "Character and Variable TypeScript types",
|
||||||
"description": "As a developer, I need the project set up with Next.js, TailwindCSS, and Supabase so that I can build the application.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Initialize Next.js project with TypeScript and App Router",
|
"Add Character type to types/flowchart.ts: id (string), name (string), color (string, hex), description (string, optional)",
|
||||||
"Install and configure TailwindCSS",
|
"Add Variable type to types/flowchart.ts: id (string), name (string), type ('numeric' | 'string' | 'boolean'), initialValue (number | string | boolean), description (string, optional)",
|
||||||
"Install Supabase client library (@supabase/supabase-js)",
|
"Update FlowchartData type to include characters: Character[] and variables: Variable[]",
|
||||||
"Create .env.example with NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
"Update DialogueNodeData to add optional characterId: string field (alongside existing speaker for migration)",
|
||||||
"Basic folder structure: app/, components/, lib/, types/",
|
"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"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 1,
|
"priority": 1,
|
||||||
|
|
@ -20,680 +21,401 @@
|
||||||
"notes": ""
|
"notes": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-002",
|
"id": "US-055",
|
||||||
"title": "Define TypeScript types for flowchart data",
|
"title": "Database schema update for characters and variables",
|
||||||
"description": "As a developer, I need TypeScript types for nodes, connections, and conditions.",
|
"description": "As a developer, I need the database schema to store characters and variables as part of the project's flowchart data.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"Create types/flowchart.ts file",
|
"Create migration that documents the new JSONB structure (characters/variables arrays stored within flowchart_data)",
|
||||||
"DialogueNode type: id, type='dialogue', position: {x,y}, data: { speaker?: string, text: string }",
|
"Update the default value for flowchart_data column to include characters: [] and variables: []",
|
||||||
"ChoiceNode type: id, type='choice', position: {x,y}, data: { prompt: string, options: { id: string, label: string }[] }",
|
"Existing projects with no characters/variables arrays continue to load (handled as empty arrays in app code)",
|
||||||
"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",
|
|
||||||
"Typecheck passes"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-054"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-003",
|
"id": "US-065",
|
||||||
"title": "Supabase schema for users and projects",
|
"title": "Searchable combobox component",
|
||||||
"description": "As a developer, I need database tables to store users and their projects.",
|
"description": "As a developer, I need a reusable searchable combobox component so that all character/variable dropdowns share consistent behavior and styling.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"Create supabase/migrations/ directory",
|
"Create components/editor/Combobox.tsx - a reusable searchable dropdown component",
|
||||||
"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)",
|
"Props: items (id, label, color?, badge?), value, onChange, placeholder, onAddNew (optional callback)",
|
||||||
"Create projects table: id (uuid), user_id (uuid, foreign key to profiles.id), name (text), flowchart_data (jsonb), created_at (timestamptz), updated_at (timestamptz)",
|
"Typing in the input filters the list by name (case-insensitive)",
|
||||||
"Add RLS policy: users can SELECT/INSERT/UPDATE/DELETE their own projects (user_id = auth.uid())",
|
"Keyboard navigation: arrow keys to move, Enter to select, Escape to close",
|
||||||
"Add RLS policy: users can SELECT their own profile",
|
"Shows color swatch and/or badge next to item labels when provided",
|
||||||
"Add RLS policy: admin users (is_admin=true) can SELECT all profiles",
|
"'Add new...' option rendered at bottom when onAddNew prop is provided",
|
||||||
"Typecheck passes"
|
"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,
|
"priority": 3,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-004",
|
"id": "US-056",
|
||||||
"title": "Supabase client configuration",
|
"title": "Character management UI in project settings",
|
||||||
"description": "As a developer, I need Supabase client utilities for auth and database access.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create lib/supabase/client.ts with browser client (createBrowserClient)",
|
"Add 'Project Settings' button to editor toolbar",
|
||||||
"Create lib/supabase/server.ts with server client (createServerClient for App Router)",
|
"Project settings opens as a modal with 'Characters' and 'Variables' tabs",
|
||||||
"Create lib/supabase/middleware.ts with middleware client helper",
|
"Characters tab shows a list of defined characters with name, color swatch, and description",
|
||||||
"Export typed database client using generated types or manual types",
|
"'Add Character' button opens inline form with: name (required), color picker (required, defaults to random), description (optional)",
|
||||||
"Typecheck passes"
|
"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,
|
"priority": 4,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-054, US-055"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-005",
|
"id": "US-057",
|
||||||
"title": "Protected routes middleware",
|
"title": "Variable management UI in project settings",
|
||||||
"description": "As a developer, I need authentication middleware so that only logged-in users can access the app.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create middleware.ts at project root",
|
"Variables tab in project settings modal shows a list of defined variables",
|
||||||
"Middleware checks Supabase session on each request",
|
"Each variable displays: name, type badge (numeric/string/boolean), initial value, description",
|
||||||
"Unauthenticated users accessing /dashboard or /editor/* are redirected to /login",
|
"'Add Variable' button opens inline form with: name (required), type dropdown (required), initial value (required, input adapts to type), description (optional)",
|
||||||
"Authenticated users accessing /login or /signup are redirected to /dashboard",
|
"Each variable row has Edit and Delete buttons",
|
||||||
"Public routes allowed without auth: /login, /signup, /forgot-password, /reset-password",
|
"Deleting a variable referenced by nodes/edges shows warning with usage count",
|
||||||
"Typecheck passes"
|
"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,
|
"priority": 5,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-054, US-055"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-006",
|
"id": "US-058",
|
||||||
"title": "Login page",
|
"title": "Dialogue node speaker dropdown",
|
||||||
"description": "As a user, I want to log in with my email and password so that I can access my projects.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create app/login/page.tsx",
|
"Replace the speaker text input in DialogueNode with the Combobox component",
|
||||||
"Form with email and password input fields",
|
"Dropdown lists all characters defined in the project, showing color swatch + name",
|
||||||
"Submit button calls Supabase signInWithPassword",
|
"Selecting a character sets characterId on the node data",
|
||||||
"Show error message for invalid credentials",
|
"Dropdown includes 'Add new character...' option at the bottom",
|
||||||
"On success, redirect to /dashboard",
|
"Clicking 'Add new character...' opens a mini form inline (name + color) that creates the character and selects it",
|
||||||
"Link to /forgot-password page",
|
"If node has a characterId that doesn't match any defined character, show orange warning border on the dropdown",
|
||||||
"Styled with TailwindCSS",
|
"Empty/unset speaker shows placeholder 'Select speaker...'",
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 6,
|
"priority": 6,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-056, US-065"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-007",
|
"id": "US-059",
|
||||||
"title": "Sign up page (invite-only)",
|
"title": "Variable node variable dropdown",
|
||||||
"description": "As an invited user, I want to complete my account setup so that I can access the tool.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create app/signup/page.tsx",
|
"Replace the variableName text input in VariableNode with the Combobox component",
|
||||||
"Form with email (pre-filled if from invite link), password, and confirm password fields",
|
"Dropdown lists all variables defined in the project, showing type badge + name",
|
||||||
"Validate passwords match before submission",
|
"Selecting a variable sets variableId on the node data",
|
||||||
"Handle Supabase invite token from URL (type=invite or type=signup)",
|
"Dropdown includes 'Add new variable...' option that opens inline creation form",
|
||||||
"On success, create profile record in profiles table and redirect to /dashboard",
|
"If node references a variableId that doesn't match any defined variable, show orange warning border",
|
||||||
"Show error message if signup fails",
|
"Operation options (set/add/subtract) are filtered based on selected variable's type (add/subtract only for numeric)",
|
||||||
"Styled with TailwindCSS",
|
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 7,
|
"priority": 7,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-057, US-065"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-008",
|
"id": "US-060",
|
||||||
"title": "Logout functionality",
|
"title": "Edge condition variable dropdown",
|
||||||
"description": "As a user, I want to log out so that I can secure my session.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create components/LogoutButton.tsx component",
|
"Replace the variableName text input in ConditionEditor with the Combobox component",
|
||||||
"Button calls Supabase signOut",
|
"Dropdown lists all variables defined in the project, showing type badge + name",
|
||||||
"On success, redirect to /login",
|
"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",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 8,
|
"priority": 8,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-057, US-065"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-009",
|
"id": "US-061",
|
||||||
"title": "Password reset - forgot password page",
|
"title": "Choice option condition variable dropdown",
|
||||||
"description": "As a user, I want to request a password reset if I forget my password.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create app/forgot-password/page.tsx",
|
"Replace the variableName text input in OptionConditionEditor with the Combobox component",
|
||||||
"Form with email input field",
|
"Dropdown lists all variables defined in the project, showing type badge + name",
|
||||||
"Submit button calls Supabase resetPasswordForEmail",
|
"Selecting a variable sets variableId on the option's condition object",
|
||||||
"Show confirmation message after sending (check your email)",
|
"Dropdown includes 'Add new variable...' option",
|
||||||
"Link back to /login",
|
"If condition references an undefined variableId, show orange warning indicator",
|
||||||
"Styled with TailwindCSS",
|
"Operator and value inputs adapt to variable type (same behavior as US-060)",
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 9,
|
"priority": 9,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-057, US-065"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-010",
|
"id": "US-062",
|
||||||
"title": "Password reset - set new password page",
|
"title": "Auto-migration of existing free-text values",
|
||||||
"description": "As a user, I want to set a new password after clicking the reset link.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create app/reset-password/page.tsx",
|
"On project load, if characters array is empty but nodes have speaker values, auto-create Character entries from unique speaker names",
|
||||||
"Form with new password and confirm password fields",
|
"Auto-created characters get randomly assigned colors and the speaker text as name",
|
||||||
"Handle Supabase recovery token from URL",
|
"On project load, if variables array is empty but nodes/edges have variableName values, auto-create Variable entries (default type: numeric, initial value: 0)",
|
||||||
"Submit calls Supabase updateUser with new password",
|
"After auto-creation, update all nodes to set characterId/variableId references pointing to the new entries",
|
||||||
"On success, redirect to /login with success message",
|
"Show a toast notification: 'Auto-imported N characters and M variables from existing data'",
|
||||||
"Show error if token invalid or expired",
|
"Migration only runs once (presence of characters/variables arrays, even if empty, means migration already happened)",
|
||||||
"Styled with TailwindCSS",
|
"Typecheck passes"
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
],
|
||||||
"priority": 10,
|
"priority": 10,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-054, US-058, US-059"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-011",
|
"id": "US-063",
|
||||||
"title": "Dashboard layout with navbar",
|
"title": "Import characters/variables from another project",
|
||||||
"description": "As a user, I want a consistent layout with navigation so that I can move around the app.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create app/dashboard/layout.tsx",
|
"Add 'Import from project' button in both Characters and Variables tabs of project settings",
|
||||||
"Navbar component with app title/logo",
|
"Button opens a modal listing the user's other projects",
|
||||||
"Navbar shows current user email",
|
"Selecting a project shows its characters (or variables) with checkboxes for selection",
|
||||||
"Navbar includes LogoutButton",
|
"User can select which entries to import (select all / none / individual)",
|
||||||
"Main content area below navbar",
|
"Imported entries are added to the current project (duplicates by name are skipped with a warning)",
|
||||||
"Styled with TailwindCSS",
|
"Imported characters keep their colors; imported variables keep their types and initial values",
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 11,
|
"priority": 11,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-056, US-057"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-012",
|
"id": "US-064",
|
||||||
"title": "Dashboard - list projects",
|
"title": "Export validation for undefined references",
|
||||||
"description": "As a user, I want to see all my projects so that I can choose which one to edit.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create app/dashboard/page.tsx",
|
"Before export, scan all nodes and edges for characterId/variableId references that don't match defined entries",
|
||||||
"Fetch projects from Supabase for current user",
|
"If issues found, show a warning modal listing: node type, node content snippet, and the undefined reference",
|
||||||
"Display projects as cards in a grid",
|
"Modal offers 'Export anyway' and 'Cancel' options",
|
||||||
"Each card shows: project name, last updated date (formatted)",
|
"Nodes with undefined references are highlighted on the canvas with orange warning borders when modal is shown",
|
||||||
"Click card navigates to /editor/[projectId]",
|
"If no issues found, export proceeds normally",
|
||||||
"Empty state with message when no projects exist",
|
|
||||||
"Loading state while fetching",
|
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 12,
|
"priority": 12,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-058, US-059, US-060, US-061"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-013",
|
"id": "US-043",
|
||||||
"title": "Create new project",
|
"title": "Database schema for collaboration sessions and audit trail",
|
||||||
"description": "As a user, I want to create a new project so that I can start a new flowchart.",
|
"description": "As a developer, I need database tables to track active collaboration sessions and store the full change history for projects.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"Add 'New Project' button on dashboard",
|
"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)",
|
||||||
"Clicking opens modal with project name input",
|
"Create collaboration_sessions table: id (uuid), project_id, user_id, cursor_position (jsonb), selected_node_id (text nullable), connected_at (timestamptz), last_heartbeat (timestamptz)",
|
||||||
"Submit creates project in Supabase with empty flowchart_data: { nodes: [], edges: [] }",
|
"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)",
|
||||||
"On success, redirect to /editor/[newProjectId]",
|
"Add RLS policies: collaborators can access sessions/audit for projects they belong to",
|
||||||
"Show error if creation fails",
|
"Add index on audit_trail(project_id, created_at) for efficient history queries",
|
||||||
"Typecheck passes",
|
"Typecheck passes"
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
],
|
||||||
"priority": 13,
|
"priority": 13,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-014",
|
"id": "US-045",
|
||||||
"title": "Delete project",
|
"title": "Supabase Realtime channel and connection management",
|
||||||
"description": "As a user, I want to delete a project I no longer need.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Add delete icon/button on each project card",
|
"Create lib/collaboration/realtime.ts module",
|
||||||
"Clicking shows confirmation dialog (Are you sure?)",
|
"On editor mount, join a Supabase Realtime channel scoped to the project ID",
|
||||||
"Confirm deletes project from Supabase",
|
"Track connection state (connecting, connected, disconnected, reconnecting)",
|
||||||
"Project removed from dashboard list without page reload",
|
"Implement heartbeat mechanism (update last_heartbeat every 30 seconds)",
|
||||||
"Show success toast after deletion",
|
"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",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 14,
|
"priority": 14,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-043"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-015",
|
"id": "US-044",
|
||||||
"title": "Rename project",
|
"title": "Project sharing and collaborator management",
|
||||||
"description": "As a user, I want to rename a project to keep my work organized.",
|
"description": "As a project owner, I want to invite other users to collaborate on my project so that we can work together.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"Add edit/rename icon on project card",
|
"Add 'Share' button in the editor toolbar",
|
||||||
"Clicking opens modal or enables inline edit for project name",
|
"Share modal displays current collaborators with roles (owner/editor/viewer)",
|
||||||
"Submit updates project name in Supabase",
|
"Owner can invite users by email with a selected role",
|
||||||
"UI updates immediately without page reload",
|
"Owner can change collaborator roles or remove collaborators",
|
||||||
"Show error if rename fails",
|
"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",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 15,
|
"priority": 15,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-043"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-016",
|
"id": "US-046",
|
||||||
"title": "Admin - invite new user",
|
"title": "Presence indicators for active collaborators",
|
||||||
"description": "As an admin, I want to invite new users so that collaborators can access the tool.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create app/admin/invite/page.tsx",
|
"Display a row of avatar circles in the editor toolbar showing connected users",
|
||||||
"Only accessible by users with is_admin=true (redirect others to /dashboard)",
|
"Each avatar shows the user's display_name on hover (tooltip)",
|
||||||
"Form with email address input",
|
"Each user is assigned a consistent color (derived from user ID hash)",
|
||||||
"Submit calls Supabase admin inviteUserByEmail (requires service role key in server action)",
|
"Avatars appear when users join and disappear when they leave",
|
||||||
"Show success message with invite sent confirmation",
|
"Maximum 5 avatars shown with '+N' overflow indicator",
|
||||||
"Show error if invite fails",
|
"Own avatar not shown in the list",
|
||||||
"Link to this page visible in navbar only for admins",
|
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 16,
|
"priority": 16,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-045"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-017",
|
"id": "US-048",
|
||||||
"title": "Editor page with React Flow canvas",
|
"title": "Integrate Yjs CRDT for conflict-free node/edge synchronization",
|
||||||
"description": "As a user, I want an editor page with a canvas where I can build my flowchart.",
|
"description": "As a developer, I need to integrate a CRDT library so that concurrent edits from multiple users merge automatically without data loss.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"Install reactflow package",
|
"Install and configure Yjs with a Supabase-compatible provider (or WebSocket provider)",
|
||||||
"Create app/editor/[projectId]/page.tsx",
|
"Create lib/collaboration/crdt.ts module wrapping Yjs document setup",
|
||||||
"Fetch project from Supabase by ID",
|
"Model flowchart nodes as a Y.Map keyed by node ID",
|
||||||
"Show error if project not found or user unauthorized",
|
"Model flowchart edges as a Y.Map keyed by edge ID",
|
||||||
"Show loading state while fetching",
|
"Local React Flow state changes are synced to the Yjs document",
|
||||||
"Render React Flow canvas filling the editor area",
|
"Remote Yjs document changes update local React Flow state",
|
||||||
"Canvas has grid background (React Flow Background component)",
|
"Initial load populates Yjs document from database state",
|
||||||
"Header shows project name with back link to /dashboard",
|
"Periodic persistence of Yjs document state to Supabase (debounced 2 seconds)",
|
||||||
"Initialize React Flow with nodes and edges from flowchart_data",
|
"Typecheck passes"
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
],
|
||||||
"priority": 17,
|
"priority": 17,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-045"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-018",
|
"id": "US-047",
|
||||||
"title": "Canvas pan and zoom controls",
|
"title": "Live cursor positions on canvas",
|
||||||
"description": "As a user, I want to pan and zoom the canvas to navigate large flowcharts.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Canvas supports click-and-drag panning (React Flow default)",
|
"Broadcast local cursor position to the Realtime channel (throttled to 50ms)",
|
||||||
"Mouse wheel zooms in/out (React Flow default)",
|
"Render remote cursors as colored arrows/pointers on the canvas with user name labels",
|
||||||
"Add React Flow Controls component with zoom +/- buttons",
|
"Cursor color matches the user's assigned presence color",
|
||||||
"Add fitView button to show all nodes",
|
"Remote cursors smoothly interpolate between position updates (no jumping)",
|
||||||
"Controls positioned in bottom-right corner",
|
"Remote cursors fade out after 5 seconds of inactivity",
|
||||||
|
"Cursors are rendered in screen coordinates and properly transform with canvas zoom/pan",
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 18,
|
"priority": 18,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-045, US-046"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-019",
|
"id": "US-050",
|
||||||
"title": "Editor toolbar",
|
"title": "Join/leave notifications",
|
||||||
"description": "As a user, I want a toolbar with actions for adding nodes and saving/exporting.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create components/editor/Toolbar.tsx",
|
"Show a toast notification when a collaborator joins: '[Name] joined'",
|
||||||
"Toolbar positioned at top of editor below header",
|
"Show a toast notification when a collaborator leaves: '[Name] left'",
|
||||||
"Buttons: Add Dialogue, Add Choice, Add Variable (no functionality yet)",
|
"Notifications use the collaborator's assigned color as an accent",
|
||||||
"Buttons: Save, Export, Import (no functionality yet)",
|
"Notifications auto-dismiss after 3 seconds (matches existing Toast behavior)",
|
||||||
"Buttons styled with TailwindCSS, icons optional",
|
"No notification shown for own join/leave events",
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 19,
|
"priority": 19,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-045, US-046"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-020",
|
"id": "US-049",
|
||||||
"title": "Create custom dialogue node component",
|
"title": "Node editing lock indicators",
|
||||||
"description": "As a user, I want dialogue nodes to display and edit character speech.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create components/editor/nodes/DialogueNode.tsx",
|
"When a user focuses/opens a node for editing, broadcast the node ID to the channel",
|
||||||
"Node styled with blue background/border",
|
"Nodes being edited by others show a colored border matching the editor's presence color",
|
||||||
"Displays editable input for speaker name (placeholder: 'Speaker')",
|
"A small label with the editor's name appears on the locked node",
|
||||||
"Displays editable textarea for dialogue text (placeholder: 'Dialogue text...')",
|
"Other users can still view but see a 'Being edited by [name]' indicator if they try to edit",
|
||||||
"Has one Handle at top (type='target', id='input')",
|
"Lock is released when the user clicks away, closes the node, or disconnects",
|
||||||
"Has one Handle at bottom (type='source', id='output')",
|
"Lock auto-expires after 60 seconds of inactivity as a safety measure",
|
||||||
"Register as custom node type in React Flow",
|
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 20,
|
"priority": 20,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-045, US-048"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-021",
|
"id": "US-051",
|
||||||
"title": "Add dialogue node from toolbar",
|
"title": "Audit trail recording",
|
||||||
"description": "As a user, I want to add dialogue nodes by clicking the toolbar button.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Clicking 'Add Dialogue' in toolbar creates new DialogueNode",
|
"Every node add/update/delete operation writes a record to audit_trail table",
|
||||||
"Node appears at center of current viewport",
|
"Every edge add/update/delete operation writes a record to audit_trail table",
|
||||||
"Node has unique ID (use nanoid or uuid)",
|
"Records include previous_state (null for additions) and new_state (null for deletions)",
|
||||||
"Node added to React Flow nodes state",
|
"Records include the acting user's ID and timestamp",
|
||||||
"Node can be dragged to reposition",
|
"Writes are batched/debounced to avoid excessive DB calls (max 1 write per second per entity)",
|
||||||
"Typecheck passes",
|
"Audit writes do not block the user's editing flow (fire-and-forget with error logging)",
|
||||||
"Verify in browser using dev-browser skill"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 21,
|
"priority": 21,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-043, US-048"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-022",
|
"id": "US-052",
|
||||||
"title": "Create custom choice node component",
|
"title": "Activity history sidebar",
|
||||||
"description": "As a user, I want choice nodes to display branching decisions.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Create components/editor/nodes/ChoiceNode.tsx",
|
"Add 'History' button to editor toolbar that opens a right sidebar panel",
|
||||||
"Node styled with green background/border",
|
"Sidebar displays a chronological list of changes with: user name, action type, entity description, timestamp",
|
||||||
"Displays editable input for prompt text (placeholder: 'What do you choose?')",
|
"Entries are grouped by time period (Today, Yesterday, Earlier)",
|
||||||
"Displays 2 default options, each with editable label input",
|
"Each entry shows the user's presence color as an accent",
|
||||||
"Has one Handle at top (type='target', id='input')",
|
"Clicking an entry highlights/selects the affected node or edge on the canvas",
|
||||||
"Each option has its own Handle at bottom (type='source', id='option-0', 'option-1', etc.)",
|
"Paginated loading (20 entries per page) with 'Load more' button",
|
||||||
"Register as custom node type in React Flow",
|
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 22,
|
"priority": 22,
|
||||||
"passes": true,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-051"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-023",
|
"id": "US-053",
|
||||||
"title": "Add choice node from toolbar",
|
"title": "Revert changes from audit trail",
|
||||||
"description": "As a user, I want to add choice nodes by clicking the toolbar button.",
|
"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": [
|
"acceptanceCriteria": [
|
||||||
"Clicking 'Add Choice' in toolbar creates new ChoiceNode",
|
"Each entry in the activity history sidebar has a 'Revert' button",
|
||||||
"Node appears at center of current viewport",
|
"Clicking 'Revert' shows a confirmation dialog with before/after preview",
|
||||||
"Node has unique ID",
|
"Reverting a node addition deletes the node",
|
||||||
"Node initialized with 2 options (each with unique id and empty label)",
|
"Reverting a node update restores the previous state",
|
||||||
"Node added to React Flow nodes state",
|
"Reverting a node deletion re-creates the node with its previous state",
|
||||||
"Node can be dragged to reposition",
|
"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",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 23,
|
"priority": 23,
|
||||||
"passes": true,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-024",
|
|
||||||
"title": "Add/remove choice options",
|
|
||||||
"description": "As a user, I want to add or remove choice options (2-6 options supported).",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"ChoiceNode has '+' button to add new option",
|
|
||||||
"Maximum 6 options (button disabled or hidden at max)",
|
|
||||||
"Each option has 'x' button to remove it",
|
|
||||||
"Minimum 2 options (remove button disabled or hidden at min)",
|
|
||||||
"Adding option creates new output Handle dynamically",
|
|
||||||
"Removing option removes its Handle",
|
|
||||||
"Node data updates in React Flow state",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 24,
|
|
||||||
"passes": true,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-025",
|
|
||||||
"title": "Create custom variable node component",
|
|
||||||
"description": "As a user, I want variable nodes to set or modify story variables.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Create components/editor/nodes/VariableNode.tsx",
|
|
||||||
"Node styled with orange background/border",
|
|
||||||
"Displays editable input for variable name (placeholder: 'variableName')",
|
|
||||||
"Displays dropdown/select for operation: set, add, subtract",
|
|
||||||
"Displays editable number input for value (default: 0)",
|
|
||||||
"Has one Handle at top (type='target', id='input')",
|
|
||||||
"Has one Handle at bottom (type='source', id='output')",
|
|
||||||
"Register as custom node type in React Flow",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 25,
|
|
||||||
"passes": true,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-026",
|
|
||||||
"title": "Add variable node from toolbar",
|
|
||||||
"description": "As a user, I want to add variable nodes by clicking the toolbar button.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Clicking 'Add Variable' in toolbar creates new VariableNode",
|
|
||||||
"Node appears at center of current viewport",
|
|
||||||
"Node has unique ID",
|
|
||||||
"Node initialized with empty variableName, operation='set', value=0",
|
|
||||||
"Node added to React Flow nodes state",
|
|
||||||
"Node can be dragged to reposition",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 26,
|
|
||||||
"passes": true,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-027",
|
|
||||||
"title": "Connect nodes with edges",
|
|
||||||
"description": "As a user, I want to connect nodes with arrows to define story flow.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Dragging from source Handle to target Handle creates edge (React Flow default)",
|
|
||||||
"Edges render as smooth bezier curves (default edge type or smoothstep)",
|
|
||||||
"Edges show arrow marker indicating direction (markerEnd)",
|
|
||||||
"Edges update position when nodes are moved",
|
|
||||||
"Cannot connect source-to-source or target-to-target (React Flow handles this)",
|
|
||||||
"New edges added to React Flow edges state",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 27,
|
|
||||||
"passes": true,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-028",
|
|
||||||
"title": "Select and delete nodes",
|
|
||||||
"description": "As a user, I want to delete nodes to revise my flowchart.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Clicking a node selects it (visual highlight via React Flow)",
|
|
||||||
"Pressing Delete or Backspace key removes selected node(s)",
|
|
||||||
"Deleting node also removes all connected edges",
|
|
||||||
"Use onNodesDelete callback to handle deletion",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 28,
|
|
||||||
"passes": true,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-029",
|
|
||||||
"title": "Select and delete edges",
|
|
||||||
"description": "As a user, I want to delete connections between nodes.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Clicking an edge selects it (visual highlight via React Flow)",
|
|
||||||
"Pressing Delete or Backspace key removes selected edge(s)",
|
|
||||||
"Use onEdgesDelete callback to handle deletion",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 29,
|
|
||||||
"passes": true,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-030",
|
|
||||||
"title": "Right-click context menu",
|
|
||||||
"description": "As a user, I want a context menu for quick actions.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Create components/editor/ContextMenu.tsx",
|
|
||||||
"Right-click on canvas shows menu: Add Dialogue, Add Choice, Add Variable",
|
|
||||||
"New node created at click position",
|
|
||||||
"Right-click on node shows menu: Delete",
|
|
||||||
"Right-click on edge shows menu: Delete, Add Condition",
|
|
||||||
"Clicking elsewhere or pressing Escape closes menu",
|
|
||||||
"Menu styled with TailwindCSS",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 30,
|
|
||||||
"passes": false,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": "Dependencies: US-052, US-048"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-031",
|
|
||||||
"title": "Condition editor modal",
|
|
||||||
"description": "As a user, I want to add conditions to edges so branches depend on variables.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Create components/editor/ConditionEditor.tsx modal/popover",
|
|
||||||
"Opens on double-click edge or via context menu 'Add Condition'",
|
|
||||||
"Form fields: variable name input, operator dropdown (>, <, ==, >=, <=, !=), value number input",
|
|
||||||
"Pre-fill fields if edge already has condition",
|
|
||||||
"Save button applies condition to edge data",
|
|
||||||
"Clear/Remove button removes condition from edge",
|
|
||||||
"Cancel button closes without saving",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 31,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-032",
|
|
||||||
"title": "Display conditions on edges",
|
|
||||||
"description": "As a user, I want to see conditions displayed on edges.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Create custom edge component or use edge labels",
|
|
||||||
"Edges with conditions render as dashed lines (strokeDasharray)",
|
|
||||||
"Condition label displayed on edge (e.g., 'score > 5')",
|
|
||||||
"Unconditional edges remain solid lines",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 32,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-033",
|
|
||||||
"title": "Auto-save to LocalStorage",
|
|
||||||
"description": "As a user, I want my work auto-saved locally so I don't lose progress if the browser crashes.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Save flowchart state (nodes + edges) to LocalStorage on every change",
|
|
||||||
"Debounce saves (e.g., 1 second delay after last change)",
|
|
||||||
"LocalStorage key format: 'vnwrite-draft-{projectId}'",
|
|
||||||
"On editor load, check LocalStorage for saved draft",
|
|
||||||
"If local draft exists and differs from database, show prompt to restore or discard",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 33,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-034",
|
|
||||||
"title": "Save project to database",
|
|
||||||
"description": "As a user, I want to save my project to the database manually.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Clicking 'Save' in toolbar saves current nodes/edges to Supabase",
|
|
||||||
"Update project's flowchart_data and updated_at fields",
|
|
||||||
"Show saving indicator/spinner while in progress",
|
|
||||||
"Show success toast on completion",
|
|
||||||
"Clear LocalStorage draft after successful save",
|
|
||||||
"Show error toast if save fails",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 34,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-035",
|
|
||||||
"title": "Export project as .vnflow file",
|
|
||||||
"description": "As a user, I want to export my project as a JSON file for backup or sharing.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Clicking 'Export' in toolbar triggers file download",
|
|
||||||
"File named '[project-name].vnflow'",
|
|
||||||
"File contains JSON with nodes and edges arrays",
|
|
||||||
"JSON is pretty-printed (2-space indent) for readability",
|
|
||||||
"Uses browser download API (create blob, trigger download)",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 35,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-036",
|
|
||||||
"title": "Import project from .vnflow file",
|
|
||||||
"description": "As a user, I want to import a .vnflow file to restore or share projects.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Clicking 'Import' in toolbar opens file picker",
|
|
||||||
"Accept .vnflow and .json file extensions",
|
|
||||||
"If current project has unsaved changes, show confirmation dialog",
|
|
||||||
"Validate imported file has nodes and edges arrays",
|
|
||||||
"Show error toast if file is invalid",
|
|
||||||
"Load valid data into React Flow state (replaces current flowchart)",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 36,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-037",
|
|
||||||
"title": "Export to Ren'Py JSON format",
|
|
||||||
"description": "As a user, I want to export my flowchart to Ren'Py-compatible JSON for use in my game.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Add 'Export to Ren'Py' option (button or dropdown item)",
|
|
||||||
"File named '[project-name]-renpy.json'",
|
|
||||||
"Dialogue nodes export as: { type: 'dialogue', speaker: '...', text: '...' }",
|
|
||||||
"Choice nodes export as: { type: 'menu', prompt: '...', choices: [{ label: '...', next: '...' }] }",
|
|
||||||
"Variable nodes export as: { type: 'variable', name: '...', operation: '...', value: ... }",
|
|
||||||
"Edges with conditions include condition object on the choice/jump",
|
|
||||||
"Organize nodes into labeled sections based on flow (traverse from first node)",
|
|
||||||
"Include metadata: projectName, exportedAt timestamp",
|
|
||||||
"Output JSON is valid (test with JSON.parse)",
|
|
||||||
"Typecheck passes"
|
|
||||||
],
|
|
||||||
"priority": 37,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-038",
|
|
||||||
"title": "Unsaved changes warning",
|
|
||||||
"description": "As a user, I want a warning before losing unsaved work.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Track dirty state: true when flowchart modified after last save",
|
|
||||||
"Set dirty=true on node/edge add, delete, or modify",
|
|
||||||
"Set dirty=false after successful save",
|
|
||||||
"Browser beforeunload event shows warning if dirty",
|
|
||||||
"Navigating to dashboard shows confirmation modal if dirty",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 38,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "US-039",
|
|
||||||
"title": "Loading and error states",
|
|
||||||
"description": "As a user, I want clear feedback when things are loading or when errors occur.",
|
|
||||||
"acceptanceCriteria": [
|
|
||||||
"Loading spinner component for async operations",
|
|
||||||
"Editor shows loading spinner while fetching project",
|
|
||||||
"Error message displayed if project fails to load (with back to dashboard link)",
|
|
||||||
"Toast notification system for success/error messages",
|
|
||||||
"Save error shows toast with retry option",
|
|
||||||
"Typecheck passes",
|
|
||||||
"Verify in browser using dev-browser skill"
|
|
||||||
],
|
|
||||||
"priority": 39,
|
|
||||||
"passes": false,
|
|
||||||
"notes": ""
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
484
progress.txt
484
progress.txt
|
|
@ -24,412 +24,162 @@
|
||||||
- Register custom node types in nodeTypes object (memoized with useMemo) and pass to ReactFlow component
|
- Register custom node types in nodeTypes object (memoized with useMemo) and pass to ReactFlow component
|
||||||
- FlowchartEditor uses ReactFlowProvider wrapper + inner component pattern for useReactFlow() hook access
|
- FlowchartEditor uses ReactFlowProvider wrapper + inner component pattern for useReactFlow() hook access
|
||||||
- Use nanoid for generating unique node IDs (import from 'nanoid')
|
- Use nanoid for generating unique node IDs (import from 'nanoid')
|
||||||
|
- Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message
|
||||||
|
- Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons
|
||||||
|
- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
|
||||||
|
- Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts`
|
||||||
|
- `EditorContext` at `src/components/editor/EditorContext.tsx` provides shared state (characters, onAddCharacter) to all custom node components via React context
|
||||||
|
- Use `useEditorContext()` in node components to access project-level characters and variables without prop drilling through React Flow node data
|
||||||
|
- New JSONB fields (characters, variables) must be defaulted to `[]` when reading from DB in page.tsx to handle pre-existing data
|
||||||
|
- Reusable `Combobox` component at `src/components/editor/Combobox.tsx` - use for all character/variable dropdowns. Props: items (ComboboxItem[]), value, onChange, placeholder, onAddNew
|
||||||
|
- `ProjectSettingsModal` at `src/components/editor/ProjectSettingsModal.tsx` manages characters/variables. Receives state + callbacks from FlowchartEditor
|
||||||
|
- Characters and variables state is managed in `FlowchartEditorInner` with `useState` hooks, passed down to the modal
|
||||||
|
- For settings-style modals, use `max-w-2xl h-[80vh]` with overflow-y-auto content area and fixed header/tabs
|
||||||
|
- `EditorContext` provides both characters (onAddCharacter) and variables (onAddVariable) to node components. Use `useEditorContext()` to access them.
|
||||||
|
- In FlowchartEditor, `handleAddVariable` adds a variable *node* to the canvas; `handleAddVariableDefinition` creates a variable *definition* in project data. Avoid naming collisions between "add node" and "add definition" callbacks.
|
||||||
|
- Edge interactions use `onEdgeClick` on ReactFlow component. ConditionEditor opens as a modal overlay since React Flow edges don't support inline panels.
|
||||||
|
- `Condition.value` supports `number | string | boolean` — always check variable type before rendering value inputs for edge conditions.
|
||||||
|
- `OptionConditionEditor` at `src/components/editor/OptionConditionEditor.tsx` handles choice option conditions. Same pattern as `ConditionEditor` but with simpler props (no edgeId).
|
||||||
|
- `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions.
|
||||||
|
- React Compiler lint forbids `setState` in effects and reading `useRef().current` during render. Use `useState(() => computeValue())` lazy initializer pattern for one-time initialization logic.
|
||||||
|
- For detecting legacy data shape (pre-migration), pass a flag from the server component (page.tsx) to the client component, since only the server reads raw DB data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-21 - US-001
|
## 2026-01-23 - US-054
|
||||||
- What was implemented: Project scaffolding and configuration
|
- What was implemented: Character and Variable TypeScript types added to `src/types/flowchart.ts`
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- package.json - project dependencies and scripts
|
- `src/types/flowchart.ts` - Added `Character`, `Variable`, `DialogueNodeData`, `VariableNodeData` types; updated `FlowchartData`, `DialogueNode`, `VariableNode`, `Condition` types
|
||||||
- tsconfig.json - TypeScript configuration
|
- `src/app/editor/[projectId]/page.tsx` - Updated FlowchartData initialization to include `characters: []` and `variables: []` defaults
|
||||||
- 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
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- Next.js 16 uses `@tailwindcss/postcss` for TailwindCSS 4 integration
|
- 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.
|
||||||
- Use --src-dir flag for create-next-app to put source in src/ folder
|
- `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.
|
||||||
- npm package names can't have capital letters (use lowercase)
|
- The new `characterId` and `variableId` fields are optional alongside existing `speaker`/`variableName` fields to support migration from free-text to referenced-entity pattern.
|
||||||
- .gitignore needs explicit exclusion for .env files, but include .env.example
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-21 - US-002
|
## 2026-01-23 - US-055
|
||||||
- What was implemented: TypeScript types for flowchart data structures
|
- What was implemented: Database migration to update flowchart_data JSONB default to include `characters: []` and `variables: []`
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- src/types/flowchart.ts - new file with all flowchart type definitions
|
- `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
|
||||||
- package.json - added typecheck script (tsc --noEmit)
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- Position is a helper type for {x, y} coordinates used by nodes
|
- 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.
|
||||||
- FlowchartNode is a union type of DialogueNode | ChoiceNode | VariableNode
|
- The app-side defaults in page.tsx (from US-054) already handle existing projects gracefully, so no data migration of existing rows is needed.
|
||||||
- ChoiceOption is a separate type to make options array cleaner
|
- For JSONB-embedded arrays, the pattern is: update the DB default for new rows + handle missing fields in app code for old rows.
|
||||||
- All types use `export type` for TypeScript isolatedModules compatibility
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-21 - US-003
|
## 2026-01-23 - US-065
|
||||||
- What was implemented: Supabase schema for users and projects
|
- What was implemented: Reusable searchable combobox component at `src/components/editor/Combobox.tsx`
|
||||||
- Files changed:
|
- 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:**
|
- **Learnings for future iterations:**
|
||||||
- Supabase migrations are plain SQL files in supabase/migrations/ directory
|
- The Combobox exports both the default component and the `ComboboxItem` type for consumers to use
|
||||||
- Migration filenames use timestamp prefix (YYYYMMDDHHMMSS_description.sql)
|
- Props: `items` (ComboboxItem[]), `value` (string | undefined), `onChange` (id: string) => void, `placeholder` (string), `onAddNew` (() => void, optional)
|
||||||
- RLS policies need separate policies for SELECT, INSERT, UPDATE, DELETE operations
|
- ComboboxItem shape: `{ id: string, label: string, color?: string, badge?: string }`
|
||||||
- Admin check policy uses EXISTS subquery to check is_admin flag on profiles table
|
- The component uses neutral zinc colors for borders/backgrounds (not blue/green/orange) so it can be reused across different node types
|
||||||
- projects table references profiles.id (not auth.users.id directly) for proper FK relationships
|
- Dropdown auto-positions above or below based on available viewport space (200px threshold)
|
||||||
- flowchart_data column uses JSONB type with default empty structure
|
- Keyboard: ArrowDown/Up navigate, Enter selects, Escape closes
|
||||||
- Added auto-update trigger for updated_at timestamp on projects table
|
- 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
|
## 2026-01-23 - US-056
|
||||||
- What was implemented: Supabase client configuration utilities
|
- What was implemented: Character management UI in the project settings modal
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- src/lib/supabase/client.ts - browser client using createBrowserClient from @supabase/ssr
|
- `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/lib/supabase/server.ts - server client for App Router with async cookies() API
|
- `src/components/editor/Toolbar.tsx` - Added `onProjectSettings` prop and "Project Settings" button to the right side of the toolbar
|
||||||
- src/lib/supabase/middleware.ts - middleware helper with updateSession function
|
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `characters` and `variables` state management, `showSettings` modal state, usage count helpers (`getCharacterUsageCount`, `getVariableUsageCount`), and ProjectSettingsModal rendering
|
||||||
- src/lib/.gitkeep - removed (no longer needed)
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- @supabase/ssr package provides createBrowserClient and createServerClient functions
|
- The ProjectSettingsModal receives `onCharactersChange` and `onVariablesChange` callbacks that directly set state in FlowchartEditor. When save is implemented, it should read from this state.
|
||||||
- Server client requires async cookies() from next/headers in Next.js 16
|
- 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).
|
||||||
- Middleware client returns both user object and supabaseResponse for route protection
|
- 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).
|
||||||
- Cookie handling uses getAll/setAll pattern for proper session management
|
- Character usage count checks dialogue nodes for `data.characterId`; variable usage count checks both variable nodes and edge conditions.
|
||||||
- setAll in server.ts wrapped in try/catch to handle Server Component limitations
|
- 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
|
## 2026-01-23 - US-057
|
||||||
- What was implemented: Protected routes middleware for authentication
|
- What was implemented: Variable management UI with full CRUD in the project settings modal Variables tab
|
||||||
- Files changed:
|
- 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:**
|
- **Learnings for future iterations:**
|
||||||
- Next.js middleware.ts must be at project root (not in src/)
|
- 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)
|
||||||
- updateSession helper from lib/supabase/middleware.ts returns { user, supabaseResponse }
|
- Initial values are stored as strings in form state and parsed to the correct type (number/string/boolean) on save via `parseInitialValue()`
|
||||||
- Use startsWith() for route matching to handle nested routes (e.g., /editor/*)
|
- Type badges use distinct colors: blue for numeric, green for string, purple for boolean - making variable types instantly recognizable in the list
|
||||||
- Matcher config excludes static files and images to avoid unnecessary middleware calls
|
- The same form patterns from CharactersTab apply: inline form within the list for editing, appended form below the list for adding
|
||||||
- Clone nextUrl before modifying pathname for redirects
|
- No browser testing tools are available; manual verification is needed.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-21 - US-006
|
## 2026-01-23 - US-059
|
||||||
- What was implemented: Login page with email/password authentication
|
- What was implemented: Variable node variable dropdown using Combobox component, replacing the free-text input
|
||||||
- Files changed:
|
- 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:**
|
- **Learnings for future iterations:**
|
||||||
- Auth pages use 'use client' directive since they need useState and form handling
|
- 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.
|
||||||
- Use createClient() from lib/supabase/client.ts for browser-side auth operations
|
- 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.
|
||||||
- supabase.auth.signInWithPassword returns { error } object for handling failures
|
- 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.
|
||||||
- useRouter from next/navigation for programmatic redirects after auth
|
- 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'.
|
||||||
- Error state displayed in red alert box with dark mode support
|
- No browser testing tools are available; manual verification is needed.
|
||||||
- Loading state disables submit button and shows "Signing in..." text
|
|
||||||
- TailwindCSS dark mode uses dark: prefix (e.g., dark:bg-zinc-950)
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-21 - US-007
|
## 2026-01-23 - US-060
|
||||||
- What was implemented: Sign up page for invite-only account setup
|
- What was implemented: Edge condition variable dropdown using Combobox component, replacing free-text input with a type-aware condition editor modal
|
||||||
- Files changed:
|
- 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:**
|
- **Learnings for future iterations:**
|
||||||
- Supabase invite tokens come via URL hash fragment (window.location.hash)
|
- Edge interactions in React Flow use `onEdgeClick` prop on the ReactFlow component (not on individual edges). The handler receives `(event: React.MouseEvent, edge: Edge)`.
|
||||||
- Parse hash with URLSearchParams after removing leading '#'
|
- 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.
|
||||||
- Check for type=invite or type=signup to detect invite 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.
|
||||||
- Use setSession() with access_token and refresh_token to establish session from invite link
|
- 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.
|
||||||
- For invited users, update password with updateUser() then create profile with upsert()
|
- Value input adapts to type: number input for numeric, text input for string, boolean dropdown for boolean.
|
||||||
- Use upsert() instead of insert() for profiles to handle edge cases
|
- The `selectedEdge` is derived via `useMemo` from `edges` state and `selectedEdgeId`, so it always reflects the latest condition data.
|
||||||
- Validate password confirmation before submission (passwords match check)
|
- No browser testing tools are available; manual verification is needed.
|
||||||
- display_name defaults to email prefix (split('@')[0])
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-21 - US-008
|
## 2026-01-23 - US-061
|
||||||
- What was implemented: Logout functionality component
|
- What was implemented: Choice option condition variable dropdown using OptionConditionEditor component with Combobox
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- src/components/LogoutButton.tsx - new client component with signOut and redirect
|
- `src/types/flowchart.ts` - Added `condition?: Condition` to `ChoiceOption` type; moved `Condition` type definition before `ChoiceOption` for correct reference order
|
||||||
- src/components/.gitkeep - removed (no longer needed)
|
- `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:**
|
- **Learnings for future iterations:**
|
||||||
- LogoutButton is a reusable component that will be used in the navbar (US-011)
|
- 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
|
||||||
- Component uses 'use client' directive for client-side auth operations
|
- The `ChoiceOption` type in `flowchart.ts` now references `Condition`, which required reordering type definitions (Condition must be defined before ChoiceOption)
|
||||||
- Loading state prevents double-clicks during signOut
|
- 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
|
||||||
- Styled with neutral zinc colors to work as a secondary button in navbars
|
- 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
|
## 2026-01-23 - US-062
|
||||||
- What was implemented: Password reset - forgot password page
|
- What was implemented: Auto-migration of existing free-text speaker/variable values to character/variable definitions on project load
|
||||||
- Files changed:
|
- 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:**
|
- **Learnings for future iterations:**
|
||||||
- resetPasswordForEmail requires redirectTo option to specify where user lands after clicking reset link
|
- 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.
|
||||||
- Use `window.location.origin` to get the current site URL for redirectTo
|
- 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.
|
||||||
- Page shows different UI after success (conditional rendering with success state)
|
- 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.
|
||||||
- Use ' for apostrophe in JSX to avoid HTML entity issues
|
- `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.
|
||||||
- Follow same styling pattern as login page for consistency across auth pages
|
- 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
|
## 2026-01-23 - US-063
|
||||||
- What was implemented: Password reset - set new password page
|
- What was implemented: Import characters/variables from another project via modal in project settings
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- src/app/reset-password/page.tsx - new file with password reset form
|
- `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/app/login/page.tsx - updated to show success message from password reset
|
- `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:**
|
- **Learnings for future iterations:**
|
||||||
- Supabase recovery tokens come via URL hash fragment with type=recovery
|
- The `ImportFromProjectModal` uses `z-[60]` to layer above the `ProjectSettingsModal` (which uses `z-50`), since it's rendered as a child of that modal
|
||||||
- Use setSession() with access_token and refresh_token from hash to establish recovery session
|
- Imported characters/variables get new IDs via `nanoid()` to avoid ID collisions between projects. The original colors, types, and initial values are preserved.
|
||||||
- Show loading state while verifying token validity (tokenValid === null)
|
- Duplicate detection is case-insensitive by name. Duplicates are skipped (not overwritten) with a warning message shown to the user.
|
||||||
- Show error state with link to request new reset if token is invalid
|
- The `LoadingSpinner` component mentioned in Codebase Patterns doesn't exist; used inline text loading indicators instead.
|
||||||
- After password update, sign out the user and redirect to login with success message
|
- 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.
|
||||||
- Use query param (message=password_reset_success) to pass success state between pages
|
- No browser testing tools are available; manual verification is needed.
|
||||||
- Login page uses useSearchParams to read and display success messages
|
|
||||||
- Success messages styled with green background (bg-green-50)
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-011
|
|
||||||
- What was implemented: Dashboard layout with navbar component
|
|
||||||
- Files changed:
|
|
||||||
- src/app/dashboard/layout.tsx - new file with dashboard layout wrapper
|
|
||||||
- src/components/Navbar.tsx - new reusable navbar component
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Dashboard layout is a server component that fetches user data via createClient() from lib/supabase/server.ts
|
|
||||||
- Navbar accepts userEmail prop to display current user
|
|
||||||
- Layout wraps children with consistent max-w-7xl container and padding
|
|
||||||
- Navbar uses Link component to allow clicking app title to go back to dashboard
|
|
||||||
- Navbar has border-b styling with dark mode support for visual separation
|
|
||||||
- Use gap-4 for spacing between navbar items (user email and logout button)
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-012
|
|
||||||
- What was implemented: Dashboard page listing user projects
|
|
||||||
- Files changed:
|
|
||||||
- src/app/dashboard/page.tsx - new file with project listing, cards, and empty state
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Dashboard page is a server component that fetches projects directly from Supabase
|
|
||||||
- Use .eq('user_id', user.id) for RLS-backed queries (though RLS also enforces this)
|
|
||||||
- Order by updated_at descending to show most recent projects first
|
|
||||||
- formatDate() helper with toLocaleDateString for human-readable dates
|
|
||||||
- Project cards use Link component for navigation to /editor/[projectId]
|
|
||||||
- Empty state uses dashed border (border-dashed) with centered content and icon
|
|
||||||
- Hover effects on cards: border-blue-300, shadow-md, and text color change on title
|
|
||||||
- Error state displayed if Supabase query fails
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-013
|
|
||||||
- What was implemented: Create new project functionality
|
|
||||||
- Files changed:
|
|
||||||
- src/components/NewProjectButton.tsx - new client component with modal dialog
|
|
||||||
- src/app/dashboard/page.tsx - added NewProjectButton to header area
|
|
||||||
- src/app/signup/page.tsx - fixed lint error (setState in effect) by initializing email from searchParams
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Modal dialogs use fixed positioning with backdrop (bg-black/50) for overlay effect
|
|
||||||
- Form submission uses Supabase insert with .select('id').single() to get the new record ID
|
|
||||||
- Initialize flowchart_data with { nodes: [], edges: [] } for new projects
|
|
||||||
- router.push() for programmatic navigation after successful creation
|
|
||||||
- autoFocus on input for better UX when modal opens
|
|
||||||
- Prevent modal close while loading (check isLoading before calling handleClose)
|
|
||||||
- ESLint rule react-hooks/set-state-in-effect warns against synchronous setState in useEffect
|
|
||||||
- Initialize state from searchParams directly in useState() instead of setting in useEffect
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-014
|
|
||||||
- What was implemented: Delete project functionality with confirmation dialog and toast
|
|
||||||
- Files changed:
|
|
||||||
- src/components/ProjectCard.tsx - new client component replacing Link, with delete button and confirmation dialog
|
|
||||||
- src/components/ProjectList.tsx - new wrapper component to manage project list state and toast notifications
|
|
||||||
- src/components/Toast.tsx - new reusable toast notification component
|
|
||||||
- src/app/dashboard/page.tsx - updated to use ProjectList instead of inline rendering
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- To enable client-side state updates (like removing items), extract list rendering from server components into client components
|
|
||||||
- ProjectList accepts initialProjects from server and manages state locally for immediate UI updates
|
|
||||||
- Use onDelete callback pattern to propagate deletion events from child (ProjectCard) to parent (ProjectList)
|
|
||||||
- Delete button uses e.stopPropagation() to prevent card click navigation when clicking delete
|
|
||||||
- Confirmation dialogs should disable close/cancel while action is in progress (isDeleting check)
|
|
||||||
- Toast component uses useCallback for handlers and auto-dismiss with setTimeout
|
|
||||||
- Toast animations can use TailwindCSS animate-in utilities (fade-in, slide-in-from-bottom-4)
|
|
||||||
- Delete icon appears on hover using group-hover:opacity-100 with parent group class
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-015
|
|
||||||
- What was implemented: Rename project functionality
|
|
||||||
- Files changed:
|
|
||||||
- src/components/ProjectCard.tsx - added rename button, modal dialog, and Supabase update logic
|
|
||||||
- src/components/ProjectList.tsx - added handleRename callback and toast notification
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Multiple action buttons on a card can be grouped in a flex container with gap-1
|
|
||||||
- Rename modal follows same pattern as delete dialog: fixed positioning, backdrop, form
|
|
||||||
- Use onKeyDown to handle Enter key for quick form submission
|
|
||||||
- Reset form state (newName, error) when opening modal to handle edge cases
|
|
||||||
- Check if name is unchanged before making API call to avoid unnecessary requests
|
|
||||||
- Trim whitespace from input value before validation and submission
|
|
||||||
- handleRename callback updates project name in state using map() to preserve list order
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-016
|
|
||||||
- What was implemented: Admin invite user functionality
|
|
||||||
- Files changed:
|
|
||||||
- src/app/admin/invite/page.tsx - new admin-only page with access check (redirects non-admins)
|
|
||||||
- src/app/admin/invite/InviteForm.tsx - client component with invite form and state management
|
|
||||||
- src/app/admin/invite/actions.ts - server action using service role key to call inviteUserByEmail
|
|
||||||
- src/components/Navbar.tsx - added isAdmin prop and "Invite User" link (visible only to admins)
|
|
||||||
- src/app/dashboard/layout.tsx - fetches profile.is_admin and passes it to Navbar
|
|
||||||
- .env.example - added SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SITE_URL
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Admin operations require SUPABASE_SERVICE_ROLE_KEY (server-side only, not NEXT_PUBLIC_*)
|
|
||||||
- Use createClient from @supabase/supabase-js directly for admin client (not @supabase/ssr)
|
|
||||||
- Admin client needs auth config: { autoRefreshToken: false, persistSession: false }
|
|
||||||
- inviteUserByEmail requires redirectTo option for the signup link in email
|
|
||||||
- Server actions ('use server') can access private env vars safely
|
|
||||||
- Admin check should happen both in server component (redirect) and server action (double check)
|
|
||||||
- Admin page uses its own layout (not dashboard layout) to have custom styling
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-017
|
|
||||||
- What was implemented: Editor page with React Flow canvas
|
|
||||||
- Files changed:
|
|
||||||
- package.json - added reactflow dependency
|
|
||||||
- src/app/editor/[projectId]/page.tsx - new server component that fetches project from Supabase, handles auth/not found, renders header with back link
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - new client component with React Flow canvas, Background component, type converters for nodes/edges
|
|
||||||
- src/app/editor/[projectId]/loading.tsx - new loading state component with spinner
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- React Flow requires 'use client' directive since it uses browser APIs
|
|
||||||
- Import 'reactflow/dist/style.css' for default React Flow styling
|
|
||||||
- Use useNodesState and useEdgesState hooks for managing nodes/edges state
|
|
||||||
- Convert app types (FlowchartNode, FlowchartEdge) to React Flow types with helper functions
|
|
||||||
- Next.js dynamic route params come as Promise in App Router 16+ (need to await params)
|
|
||||||
- Use notFound() from next/navigation for 404 responses
|
|
||||||
- React Flow canvas needs parent container with explicit height (h-full, h-screen)
|
|
||||||
- Background component accepts variant (Dots, Lines, Cross) and gap/size props
|
|
||||||
- Loading page (loading.tsx) provides automatic loading UI for async server components
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-018
|
|
||||||
- What was implemented: Canvas pan and zoom controls
|
|
||||||
- Files changed:
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added Controls import and component
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- React Flow Controls component provides zoom +/-, fitView, and lock buttons out of the box
|
|
||||||
- Use position="bottom-right" prop to position controls in bottom-right corner
|
|
||||||
- Pan (click-and-drag) and zoom (mouse wheel) are React Flow defaults, no extra config needed
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-019
|
|
||||||
- What was implemented: Editor toolbar with add/save/export/import buttons
|
|
||||||
- Files changed:
|
|
||||||
- src/components/editor/Toolbar.tsx - new toolbar component with styled buttons
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated toolbar with placeholder handlers
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Toolbar component accepts callback props for actions (onAddDialogue, onSave, etc.)
|
|
||||||
- Node type buttons use color coding: blue (Dialogue), green (Choice), orange (Variable)
|
|
||||||
- Action buttons (Save, Export, Import) use neutral bordered styling
|
|
||||||
- FlowchartEditor now uses flex-col layout to stack toolbar above canvas
|
|
||||||
- Placeholder handlers with TODO comments help track future implementation work
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-020
|
|
||||||
- What was implemented: Custom DialogueNode component for displaying/editing character dialogue
|
|
||||||
- Files changed:
|
|
||||||
- src/components/editor/nodes/DialogueNode.tsx - new custom node component with editable speaker and text fields
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered DialogueNode as custom node type
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Custom React Flow nodes use NodeProps<T> for typing, where T is the data shape
|
|
||||||
- Use useReactFlow() hook to get setNodes for updating node data from within the node component
|
|
||||||
- Handle components need Position enum (Position.Top, Position.Bottom) for positioning
|
|
||||||
- Custom handles can be styled with className and TailwindCSS, use ! prefix to override defaults (e.g., !h-3, !w-3)
|
|
||||||
- Node types must be registered in a nodeTypes object and passed to ReactFlow component
|
|
||||||
- Memoize nodeTypes with useMemo to prevent unnecessary re-renders
|
|
||||||
- Custom node components go in src/components/editor/nodes/ directory
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-021
|
|
||||||
- What was implemented: Add dialogue node from toolbar functionality
|
|
||||||
- Files changed:
|
|
||||||
- package.json - added nanoid dependency for unique ID generation
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddDialogue to create new dialogue nodes at viewport center
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- useReactFlow() hook requires ReactFlowProvider wrapper, so split component into inner component and outer wrapper
|
|
||||||
- getViewport() returns { x, y, zoom } representing the current pan/zoom state
|
|
||||||
- Calculate viewport center: centerX = (-viewport.x + halfWidth) / viewport.zoom
|
|
||||||
- nanoid v5+ generates unique IDs synchronously with no dependencies
|
|
||||||
- Node creation pattern: create Node object with { id, type, position, data }, then add to state via setNodes
|
|
||||||
- React Flow nodes are draggable by default, no extra configuration needed
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-022
|
|
||||||
- What was implemented: Custom ChoiceNode component for displaying branching decisions
|
|
||||||
- Files changed:
|
|
||||||
- src/components/editor/nodes/ChoiceNode.tsx - new custom node component with green styling, editable prompt, and dynamic option handles
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered ChoiceNode as custom node type
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- ChoiceNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
|
|
||||||
- Dynamic handles positioned using style={{ left: `${((index + 1) / (options.length + 1)) * 100}%` }} for even spacing
|
|
||||||
- Handle id format for options: 'option-0', 'option-1', etc. (matching the index)
|
|
||||||
- Each option needs a unique id (string) and label (string) per the ChoiceOption type
|
|
||||||
- updateOptionLabel callback pattern: find option by id, map over options array to update matching one
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-023
|
|
||||||
- What was implemented: Add choice node from toolbar functionality
|
|
||||||
- Files changed:
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddChoice to create new choice nodes at viewport center
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- handleAddChoice follows same pattern as handleAddDialogue: get viewport center, create node with nanoid, add to state
|
|
||||||
- Choice nodes must be initialized with 2 options (each with unique id via nanoid and empty label)
|
|
||||||
- Node data structure for choice: { prompt: '', options: [{ id, label }, { id, label }] }
|
|
||||||
- React Flow nodes are draggable by default after being added to state
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-024
|
|
||||||
- What was implemented: Add/remove choice options functionality (2-6 options supported)
|
|
||||||
- Files changed:
|
|
||||||
- src/components/editor/nodes/ChoiceNode.tsx - added addOption and removeOption callbacks, '+' button to add options, 'x' button per option to remove
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Define MIN_OPTIONS and MAX_OPTIONS constants for clear limits
|
|
||||||
- Use disabled prop on buttons to enforce min/max constraints with appropriate styling (opacity-30, cursor-not-allowed)
|
|
||||||
- Remove button uses × character for simple cross icon
|
|
||||||
- Add button styled with border-dashed for visual distinction from action buttons
|
|
||||||
- Handles update dynamically via React Flow re-render when options array changes
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-025
|
|
||||||
- What was implemented: Custom VariableNode component for setting/modifying story variables
|
|
||||||
- Files changed:
|
|
||||||
- src/components/editor/nodes/VariableNode.tsx - new custom node component with orange styling, editable variable name, operation dropdown, and numeric value input
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - imported and registered VariableNode in nodeTypes
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- VariableNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
|
|
||||||
- Use parseFloat() with fallback to 0 for number input handling: `parseFloat(e.target.value) || 0`
|
|
||||||
- Operation dropdown uses select element with options for 'set', 'add', 'subtract'
|
|
||||||
- Type assertion needed for select value: `e.target.value as 'set' | 'add' | 'subtract'`
|
|
||||||
- Use `??` (nullish coalescing) for number defaults instead of `||` to allow 0 values: `data.value ?? 0`
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-026
|
|
||||||
- What was implemented: Add variable node from toolbar functionality
|
|
||||||
- Files changed:
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddVariable to create new variable nodes at viewport center
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- handleAddVariable follows same pattern as handleAddDialogue and handleAddChoice: get viewport center, create node with nanoid, add to state
|
|
||||||
- Variable nodes initialized with { variableName: '', operation: 'set', value: 0 }
|
|
||||||
- All add node handlers share the same pattern and use the getViewportCenter helper
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-027
|
|
||||||
- What was implemented: Connect nodes with edges including arrow markers and smooth styling
|
|
||||||
- Files changed:
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added MarkerType import, updated onConnect to create edges with smoothstep type and arrow markers, updated toReactFlowEdges to apply same styling to loaded edges
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Use `type: 'smoothstep'` for cleaner edge curves instead of default bezier
|
|
||||||
- Use `markerEnd: { type: MarkerType.ArrowClosed }` to add directional arrows to edges
|
|
||||||
- Connection type has nullable source/target, but Edge requires non-null strings - guard with early return
|
|
||||||
- Apply consistent edge styling in both onConnect (new edges) and toReactFlowEdges (loaded edges)
|
|
||||||
- Generate unique edge IDs with nanoid in onConnect callback
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-028
|
|
||||||
- What was implemented: Select and delete nodes functionality
|
|
||||||
- Files changed:
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added deleteKeyCode prop to enable Delete/Backspace key deletion
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- React Flow has built-in node selection via clicking - no extra configuration needed
|
|
||||||
- Use `deleteKeyCode={['Delete', 'Backspace']}` prop to enable keyboard deletion
|
|
||||||
- React Flow automatically removes connected edges when a node is deleted (no manual cleanup needed)
|
|
||||||
- The useNodesState/useEdgesState hooks with onNodesChange/onEdgesChange handle all deletion state updates
|
|
||||||
- No explicit onNodesDelete callback is needed - the onNodesChange handler covers deletion events
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-01-21 - US-029
|
|
||||||
- What was implemented: Select and delete edges functionality
|
|
||||||
- Files changed:
|
|
||||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added onEdgesDelete callback
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- React Flow 11 edges are clickable and selectable by default (interactionWidth renders invisible interaction area)
|
|
||||||
- The `deleteKeyCode` prop works for both nodes and edges - same configuration covers both
|
|
||||||
- onEdgesDelete is optional if you just need state management (onEdgesChange handles removal events)
|
|
||||||
- onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo
|
|
||||||
- Edge selection shows visual highlight via React Flow's built-in styling
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react'
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
|
@ -22,11 +22,16 @@ import Toolbar from '@/components/editor/Toolbar'
|
||||||
import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
||||||
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
||||||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||||
import type { FlowchartData, FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||||
|
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||||
|
import { EditorProvider } from '@/components/editor/EditorContext'
|
||||||
|
import Toast from '@/components/Toast'
|
||||||
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
type FlowchartEditorProps = {
|
type FlowchartEditorProps = {
|
||||||
projectId: string
|
projectId: string
|
||||||
initialData: FlowchartData
|
initialData: FlowchartData
|
||||||
|
needsMigration?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert our FlowchartNode type to React Flow Node type
|
// Convert our FlowchartNode type to React Flow Node type
|
||||||
|
|
@ -55,8 +60,148 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RANDOM_COLORS = [
|
||||||
|
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||||
|
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||||
|
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||||
|
]
|
||||||
|
|
||||||
|
function randomHexColor(): string {
|
||||||
|
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute auto-migration of existing free-text values to character/variable definitions
|
||||||
|
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||||
|
if (!shouldMigrate) {
|
||||||
|
return {
|
||||||
|
characters: initialData.characters,
|
||||||
|
variables: initialData.variables,
|
||||||
|
nodes: initialData.nodes,
|
||||||
|
edges: initialData.edges,
|
||||||
|
toastMessage: null as string | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique speaker names from dialogue nodes
|
||||||
|
const speakerNames = new Set<string>()
|
||||||
|
initialData.nodes.forEach((node) => {
|
||||||
|
if (node.type === 'dialogue' && node.data?.speaker) {
|
||||||
|
speakerNames.add(node.data.speaker)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create character definitions from unique speaker names
|
||||||
|
const newCharacters: Character[] = []
|
||||||
|
const speakerToCharacterId = new Map<string, string>()
|
||||||
|
speakerNames.forEach((name) => {
|
||||||
|
const id = nanoid()
|
||||||
|
newCharacters.push({ id, name, color: randomHexColor() })
|
||||||
|
speakerToCharacterId.set(name, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collect unique variable names from variable nodes, edge conditions, and choice option conditions
|
||||||
|
const variableNames = new Set<string>()
|
||||||
|
initialData.nodes.forEach((node) => {
|
||||||
|
if (node.type === 'variable' && node.data.variableName) {
|
||||||
|
variableNames.add(node.data.variableName)
|
||||||
|
}
|
||||||
|
if (node.type === 'choice' && node.data.options) {
|
||||||
|
node.data.options.forEach((opt) => {
|
||||||
|
if (opt.condition?.variableName) {
|
||||||
|
variableNames.add(opt.condition.variableName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
initialData.edges.forEach((edge) => {
|
||||||
|
if (edge.data?.condition?.variableName) {
|
||||||
|
variableNames.add(edge.data.condition.variableName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create variable definitions from unique variable names
|
||||||
|
const newVariables: Variable[] = []
|
||||||
|
const varNameToId = new Map<string, string>()
|
||||||
|
variableNames.forEach((name) => {
|
||||||
|
const id = nanoid()
|
||||||
|
newVariables.push({ id, name, type: 'numeric', initialValue: 0 })
|
||||||
|
varNameToId.set(name, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// If nothing to migrate, return original data
|
||||||
|
if (newCharacters.length === 0 && newVariables.length === 0) {
|
||||||
|
return {
|
||||||
|
characters: initialData.characters,
|
||||||
|
variables: initialData.variables,
|
||||||
|
nodes: initialData.nodes,
|
||||||
|
edges: initialData.edges,
|
||||||
|
toastMessage: null as string | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nodes with characterId/variableId references
|
||||||
|
const migratedNodes = initialData.nodes.map((node) => {
|
||||||
|
if (node.type === 'dialogue' && node.data.speaker) {
|
||||||
|
const characterId = speakerToCharacterId.get(node.data.speaker)
|
||||||
|
if (characterId) {
|
||||||
|
return { ...node, data: { ...node.data, characterId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'variable' && node.data.variableName) {
|
||||||
|
const variableId = varNameToId.get(node.data.variableName)
|
||||||
|
if (variableId) {
|
||||||
|
return { ...node, data: { ...node.data, variableId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'choice' && node.data.options) {
|
||||||
|
const updatedOptions = node.data.options.map((opt) => {
|
||||||
|
if (opt.condition?.variableName) {
|
||||||
|
const variableId = varNameToId.get(opt.condition.variableName)
|
||||||
|
if (variableId) {
|
||||||
|
return { ...opt, condition: { ...opt.condition, variableId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opt
|
||||||
|
})
|
||||||
|
return { ...node, data: { ...node.data, options: updatedOptions } }
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}) as typeof initialData.nodes
|
||||||
|
|
||||||
|
// Update edges with variableId references
|
||||||
|
const migratedEdges = initialData.edges.map((edge) => {
|
||||||
|
if (edge.data?.condition?.variableName) {
|
||||||
|
const variableId = varNameToId.get(edge.data.condition.variableName)
|
||||||
|
if (variableId) {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
data: { ...edge.data, condition: { ...edge.data.condition, variableId } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edge
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build toast message
|
||||||
|
const parts: string[] = []
|
||||||
|
if (newCharacters.length > 0) {
|
||||||
|
parts.push(`${newCharacters.length} character${newCharacters.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
if (newVariables.length > 0) {
|
||||||
|
parts.push(`${newVariables.length} variable${newVariables.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
characters: newCharacters,
|
||||||
|
variables: newVariables,
|
||||||
|
nodes: migratedNodes,
|
||||||
|
edges: migratedEdges,
|
||||||
|
toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inner component that uses useReactFlow hook
|
// Inner component that uses useReactFlow hook
|
||||||
function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
|
||||||
// Define custom node types - memoized to prevent re-renders
|
// Define custom node types - memoized to prevent re-renders
|
||||||
const nodeTypes: NodeTypes = useMemo(
|
const nodeTypes: NodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -69,11 +214,73 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
|
|
||||||
const { getViewport } = useReactFlow()
|
const { getViewport } = useReactFlow()
|
||||||
|
|
||||||
|
// Compute migrated data once on first render using a lazy state initializer
|
||||||
|
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||||
toReactFlowNodes(initialData.nodes)
|
toReactFlowNodes(migratedData.nodes)
|
||||||
)
|
)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||||
toReactFlowEdges(initialData.edges)
|
toReactFlowEdges(migratedData.edges)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
|
||||||
|
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||||
|
|
||||||
|
const handleAddCharacter = useCallback(
|
||||||
|
(name: string, color: string): string => {
|
||||||
|
const id = nanoid()
|
||||||
|
const newCharacter: Character = { id, name, color }
|
||||||
|
setCharacters((prev) => [...prev, newCharacter])
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddVariableDefinition = useCallback(
|
||||||
|
(name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean): string => {
|
||||||
|
const id = nanoid()
|
||||||
|
const newVariable: Variable = { id, name, type, initialValue }
|
||||||
|
setVariables((prev) => [...prev, newVariable])
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const editorContextValue = useMemo(
|
||||||
|
() => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }),
|
||||||
|
[characters, handleAddCharacter, variables, handleAddVariableDefinition]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getCharacterUsageCount = useCallback(
|
||||||
|
(characterId: string) => {
|
||||||
|
return nodes.filter((n) => n.type === 'dialogue' && n.data?.characterId === characterId).length
|
||||||
|
},
|
||||||
|
[nodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getVariableUsageCount = useCallback(
|
||||||
|
(variableId: string) => {
|
||||||
|
const nodeCount = nodes.filter(
|
||||||
|
(n) => n.type === 'variable' && n.data?.variableId === variableId
|
||||||
|
).length
|
||||||
|
const edgeCount = edges.filter(
|
||||||
|
(e) => e.data?.condition?.variableId === variableId
|
||||||
|
).length
|
||||||
|
const choiceOptionCount = nodes.filter(
|
||||||
|
(n) => n.type === 'choice'
|
||||||
|
).reduce((count, n) => {
|
||||||
|
const options = n.data?.options || []
|
||||||
|
return count + options.filter(
|
||||||
|
(opt: { condition?: { variableId?: string } }) => opt.condition?.variableId === variableId
|
||||||
|
).length
|
||||||
|
}, 0)
|
||||||
|
return nodeCount + edgeCount + choiceOptionCount
|
||||||
|
},
|
||||||
|
[nodes, edges]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
|
|
@ -168,33 +375,89 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) {
|
||||||
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
|
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Handle edge click to open condition editor
|
||||||
|
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
|
||||||
|
setSelectedEdgeId(edge.id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle condition change from ConditionEditor
|
||||||
|
const handleConditionChange = useCallback(
|
||||||
|
(edgeId: string, condition: Condition | undefined) => {
|
||||||
|
setEdges((eds) =>
|
||||||
|
eds.map((edge) =>
|
||||||
|
edge.id === edgeId
|
||||||
|
? { ...edge, data: condition ? { condition } : undefined }
|
||||||
|
: edge
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[setEdges]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get the selected edge's condition data
|
||||||
|
const selectedEdge = useMemo(
|
||||||
|
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null),
|
||||||
|
[selectedEdgeId, edges]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<EditorProvider value={editorContextValue}>
|
||||||
<Toolbar
|
<div className="flex h-full w-full flex-col">
|
||||||
onAddDialogue={handleAddDialogue}
|
<Toolbar
|
||||||
onAddChoice={handleAddChoice}
|
onAddDialogue={handleAddDialogue}
|
||||||
onAddVariable={handleAddVariable}
|
onAddChoice={handleAddChoice}
|
||||||
onSave={handleSave}
|
onAddVariable={handleAddVariable}
|
||||||
onExport={handleExport}
|
onSave={handleSave}
|
||||||
onImport={handleImport}
|
onExport={handleExport}
|
||||||
/>
|
onImport={handleImport}
|
||||||
<div className="flex-1">
|
onProjectSettings={() => setShowSettings(true)}
|
||||||
<ReactFlow
|
/>
|
||||||
nodes={nodes}
|
<div className="flex-1">
|
||||||
edges={edges}
|
<ReactFlow
|
||||||
nodeTypes={nodeTypes}
|
nodes={nodes}
|
||||||
onNodesChange={onNodesChange}
|
edges={edges}
|
||||||
onEdgesChange={onEdgesChange}
|
nodeTypes={nodeTypes}
|
||||||
onEdgesDelete={onEdgesDelete}
|
onNodesChange={onNodesChange}
|
||||||
onConnect={onConnect}
|
onEdgesChange={onEdgesChange}
|
||||||
deleteKeyCode={['Delete', 'Backspace']}
|
onEdgesDelete={onEdgesDelete}
|
||||||
fitView
|
onEdgeClick={onEdgeClick}
|
||||||
>
|
onConnect={onConnect}
|
||||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
deleteKeyCode={['Delete', 'Backspace']}
|
||||||
<Controls position="bottom-right" />
|
fitView
|
||||||
</ReactFlow>
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
|
<Controls position="bottom-right" />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
{showSettings && (
|
||||||
|
<ProjectSettingsModal
|
||||||
|
projectId={projectId}
|
||||||
|
characters={characters}
|
||||||
|
variables={variables}
|
||||||
|
onCharactersChange={setCharacters}
|
||||||
|
onVariablesChange={setVariables}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
getCharacterUsageCount={getCharacterUsageCount}
|
||||||
|
getVariableUsageCount={getVariableUsageCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedEdge && (
|
||||||
|
<ConditionEditor
|
||||||
|
edgeId={selectedEdge.id}
|
||||||
|
condition={selectedEdge.data?.condition}
|
||||||
|
onChange={handleConditionChange}
|
||||||
|
onClose={() => setSelectedEdgeId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{toastMessage && (
|
||||||
|
<Toast
|
||||||
|
message={toastMessage}
|
||||||
|
type="success"
|
||||||
|
onClose={() => setToastMessage(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</EditorProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,17 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowchartData = (project.flowchart_data || {
|
const rawData = project.flowchart_data || {}
|
||||||
nodes: [],
|
const flowchartData: FlowchartData = {
|
||||||
edges: [],
|
nodes: rawData.nodes || [],
|
||||||
}) as FlowchartData
|
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 (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
|
|
@ -67,6 +74,7 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
<FlowchartEditor
|
<FlowchartEditor
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
initialData={flowchartData}
|
initialData={flowchartData}
|
||||||
|
needsMigration={needsMigration}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
|
export type ComboboxItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
color?: string
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComboboxProps = {
|
||||||
|
items: ComboboxItem[]
|
||||||
|
value: string | undefined
|
||||||
|
onChange: (id: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
onAddNew?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Combobox({
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Select...',
|
||||||
|
onAddNew,
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below')
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(null)
|
||||||
|
|
||||||
|
const selectedItem = useMemo(
|
||||||
|
() => items.find((item) => item.id === value),
|
||||||
|
[items, value]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredItems = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter((item) =>
|
||||||
|
item.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
),
|
||||||
|
[items, search]
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalOptions = filteredItems.length + (onAddNew ? 1 : 0)
|
||||||
|
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom
|
||||||
|
const spaceAbove = rect.top
|
||||||
|
setDropdownPosition(spaceBelow < 200 && spaceAbove > spaceBelow ? 'above' : 'below')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const open = useCallback(() => {
|
||||||
|
setIsOpen(true)
|
||||||
|
setSearch('')
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
updateDropdownPosition()
|
||||||
|
}, [updateDropdownPosition])
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false)
|
||||||
|
setSearch('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
onChange(id)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
[onChange, close]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isOpen, close])
|
||||||
|
|
||||||
|
// Scroll highlighted item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !listRef.current) return
|
||||||
|
const items = listRef.current.querySelectorAll('[data-combobox-item]')
|
||||||
|
const highlighted = items[highlightedIndex]
|
||||||
|
if (highlighted) {
|
||||||
|
highlighted.scrollIntoView({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, isOpen])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlightedIndex((prev) => (prev + 1) % totalOptions)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions)
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (highlightedIndex < filteredItems.length) {
|
||||||
|
selectItem(filteredItems[highlightedIndex].id)
|
||||||
|
} else if (onAddNew && highlightedIndex === filteredItems.length) {
|
||||||
|
onAddNew()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isOpen, open, close, highlightedIndex, filteredItems, totalOptions, selectItem, onAddNew]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full">
|
||||||
|
<div
|
||||||
|
className="flex w-full cursor-pointer items-center rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (isOpen) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
open()
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full bg-transparent outline-none placeholder-zinc-400 dark:placeholder-zinc-500"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={selectedItem ? '' : 'text-zinc-400 dark:text-zinc-500'}>
|
||||||
|
{selectedItem ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{selectedItem.color && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-full border border-zinc-300 dark:border-zinc-600"
|
||||||
|
style={{ backgroundColor: selectedItem.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedItem.badge && (
|
||||||
|
<span className="rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||||
|
{selectedItem.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedItem.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
placeholder
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
className={`ml-auto h-4 w-4 shrink-0 text-zinc-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
className={`absolute z-50 max-h-48 w-full overflow-auto rounded border border-zinc-300 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-800 ${
|
||||||
|
dropdownPosition === 'above' ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filteredItems.length === 0 && !onAddNew && (
|
||||||
|
<li className="px-2 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
No results found
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
data-combobox-item
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 px-2 py-1.5 text-sm ${
|
||||||
|
highlightedIndex === index
|
||||||
|
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
|
||||||
|
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700'
|
||||||
|
} ${item.id === value ? 'font-medium' : ''}`}
|
||||||
|
onClick={() => selectItem(item.id)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
{item.color && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 shrink-0 rounded-full border border-zinc-300 dark:border-zinc-600"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.badge && (
|
||||||
|
<span className="shrink-0 rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{onAddNew && (
|
||||||
|
<li
|
||||||
|
data-combobox-item
|
||||||
|
className={`flex cursor-pointer items-center gap-1.5 border-t border-zinc-200 px-2 py-1.5 text-sm dark:border-zinc-700 ${
|
||||||
|
highlightedIndex === filteredItems.length
|
||||||
|
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
|
||||||
|
: 'text-blue-600 hover:bg-zinc-100 dark:text-blue-400 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onAddNew()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(filteredItems.length)}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<span>Add new...</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import Combobox from '@/components/editor/Combobox'
|
||||||
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||||
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
import type { Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type ConditionEditorProps = {
|
||||||
|
edgeId: string
|
||||||
|
condition: Condition | undefined
|
||||||
|
onChange: (edgeId: string, condition: Condition | undefined) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConditionEditor({
|
||||||
|
edgeId,
|
||||||
|
condition,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}: ConditionEditorProps) {
|
||||||
|
const { variables, onAddVariable } = useEditorContext()
|
||||||
|
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||||
|
|
||||||
|
const variableItems: ComboboxItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
variables.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
label: v.name,
|
||||||
|
badge: v.type,
|
||||||
|
})),
|
||||||
|
[variables]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedVariable = useMemo(() => {
|
||||||
|
if (!condition?.variableId) return undefined
|
||||||
|
return variables.find((v) => v.id === condition.variableId)
|
||||||
|
}, [condition?.variableId, variables])
|
||||||
|
|
||||||
|
const hasInvalidReference = useMemo(() => {
|
||||||
|
if (!condition?.variableId) return false
|
||||||
|
return !variables.some((v) => v.id === condition.variableId)
|
||||||
|
}, [condition?.variableId, variables])
|
||||||
|
|
||||||
|
// Determine operators based on variable type
|
||||||
|
const availableOperators = useMemo(() => {
|
||||||
|
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
||||||
|
return [
|
||||||
|
{ value: '==', label: '==' },
|
||||||
|
{ value: '!=', label: '!=' },
|
||||||
|
{ value: '>', label: '>' },
|
||||||
|
{ value: '<', label: '<' },
|
||||||
|
{ value: '>=', label: '>=' },
|
||||||
|
{ value: '<=', label: '<=' },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
// string and boolean only support == and !=
|
||||||
|
return [
|
||||||
|
{ value: '==', label: '==' },
|
||||||
|
{ value: '!=', label: '!=' },
|
||||||
|
] as const
|
||||||
|
}, [selectedVariable])
|
||||||
|
|
||||||
|
const handleVariableSelect = useCallback(
|
||||||
|
(variableId: string) => {
|
||||||
|
const variable = variables.find((v) => v.id === variableId)
|
||||||
|
const defaultValue = variable
|
||||||
|
? variable.type === 'numeric'
|
||||||
|
? 0
|
||||||
|
: variable.type === 'boolean'
|
||||||
|
? false
|
||||||
|
: ''
|
||||||
|
: 0
|
||||||
|
// Reset operator if current one is not valid for new type
|
||||||
|
const validOperator =
|
||||||
|
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
||||||
|
? '=='
|
||||||
|
: condition?.operator || '=='
|
||||||
|
|
||||||
|
onChange(edgeId, {
|
||||||
|
variableName: variable?.name || '',
|
||||||
|
variableId,
|
||||||
|
operator: validOperator as Condition['operator'],
|
||||||
|
value: defaultValue,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[variables, condition?.operator, edgeId, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOperatorChange = useCallback(
|
||||||
|
(operator: string) => {
|
||||||
|
if (!condition) return
|
||||||
|
onChange(edgeId, {
|
||||||
|
...condition,
|
||||||
|
operator: operator as Condition['operator'],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[condition, edgeId, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(value: number | string | boolean) => {
|
||||||
|
if (!condition) return
|
||||||
|
onChange(edgeId, {
|
||||||
|
...condition,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[condition, edgeId, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemoveCondition = useCallback(() => {
|
||||||
|
onChange(edgeId, undefined)
|
||||||
|
onClose()
|
||||||
|
}, [edgeId, onChange, onClose])
|
||||||
|
|
||||||
|
const handleAddNew = useCallback(() => {
|
||||||
|
setShowAddForm(true)
|
||||||
|
setNewName('')
|
||||||
|
setNewType('numeric')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmitNew = useCallback(() => {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||||
|
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||||
|
onChange(edgeId, {
|
||||||
|
variableName: newName.trim(),
|
||||||
|
variableId: newId,
|
||||||
|
operator: '==',
|
||||||
|
value: defaultValue,
|
||||||
|
})
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [newName, newType, onAddVariable, edgeId, onChange])
|
||||||
|
|
||||||
|
const handleCancelNew = useCallback(() => {
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Render value input based on variable type
|
||||||
|
const renderValueInput = () => {
|
||||||
|
const varType = selectedVariable?.type || 'numeric'
|
||||||
|
|
||||||
|
if (varType === 'boolean') {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={String(condition?.value ?? false)}
|
||||||
|
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (varType === 'string') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={String(condition?.value ?? '')}
|
||||||
|
onChange={(e) => handleValueChange(e.target.value)}
|
||||||
|
placeholder="Value..."
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// numeric
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={typeof condition?.value === 'number' ? condition.value : 0}
|
||||||
|
onChange={(e) => handleValueChange(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||||
|
Edge Condition
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variable selector */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
Variable
|
||||||
|
</label>
|
||||||
|
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
||||||
|
<Combobox
|
||||||
|
items={variableItems}
|
||||||
|
value={condition?.variableId}
|
||||||
|
onChange={handleVariableSelect}
|
||||||
|
placeholder="Select variable..."
|
||||||
|
onAddNew={handleAddNew}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasInvalidReference && (
|
||||||
|
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Variable not found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline add form */}
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
||||||
|
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
New variable
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Name"
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSubmitNew()
|
||||||
|
if (e.key === 'Escape') handleCancelNew()
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
|
<select
|
||||||
|
value={newType}
|
||||||
|
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||||
|
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="numeric">numeric</option>
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="boolean">boolean</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleCancelNew}
|
||||||
|
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitNew}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Operator and value (shown when variable is selected) */}
|
||||||
|
{condition?.variableId && (
|
||||||
|
<>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
Operator
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={condition.operator || '=='}
|
||||||
|
onChange={(e) => handleOperatorChange(e.target.value)}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{availableOperators.map((op) => (
|
||||||
|
<option key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
{renderValueInput()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{condition?.variableId ? (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveCondition}
|
||||||
|
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
Remove condition
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import type { Character, Variable } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type EditorContextValue = {
|
||||||
|
characters: Character[]
|
||||||
|
onAddCharacter: (name: string, color: string) => string // returns new character id
|
||||||
|
variables: Variable[]
|
||||||
|
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorContext = createContext<EditorContextValue>({
|
||||||
|
characters: [],
|
||||||
|
onAddCharacter: () => '',
|
||||||
|
variables: [],
|
||||||
|
onAddVariable: () => '',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const EditorProvider = EditorContext.Provider
|
||||||
|
|
||||||
|
export function useEditorContext() {
|
||||||
|
return useContext(EditorContext)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import type { Character, Variable } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type ImportMode = 'characters' | 'variables'
|
||||||
|
|
||||||
|
type ProjectListItem = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportFromProjectModalProps = {
|
||||||
|
mode: ImportMode
|
||||||
|
currentProjectId: string
|
||||||
|
existingCharacters: Character[]
|
||||||
|
existingVariables: Variable[]
|
||||||
|
onImportCharacters: (characters: Character[]) => void
|
||||||
|
onImportVariables: (variables: Variable[]) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportFromProjectModal({
|
||||||
|
mode,
|
||||||
|
currentProjectId,
|
||||||
|
existingCharacters,
|
||||||
|
existingVariables,
|
||||||
|
onImportCharacters,
|
||||||
|
onImportVariables,
|
||||||
|
onClose,
|
||||||
|
}: ImportFromProjectModalProps) {
|
||||||
|
const [projects, setProjects] = useState<ProjectListItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
||||||
|
const [sourceCharacters, setSourceCharacters] = useState<Character[]>([])
|
||||||
|
const [sourceVariables, setSourceVariables] = useState<Variable[]>([])
|
||||||
|
const [loadingSource, setLoadingSource] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Load user's projects on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchProjects() {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error: fetchError } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, name')
|
||||||
|
.neq('id', currentProjectId)
|
||||||
|
.order('name')
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
setError('Failed to load projects')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjects(data || [])
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProjects()
|
||||||
|
}, [currentProjectId])
|
||||||
|
|
||||||
|
// Load source project's characters/variables when a project is selected
|
||||||
|
const handleSelectProject = async (projectId: string) => {
|
||||||
|
setSelectedProjectId(projectId)
|
||||||
|
setLoadingSource(true)
|
||||||
|
setWarnings([])
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error: fetchError } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('flowchart_data')
|
||||||
|
.eq('id', projectId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError || !data) {
|
||||||
|
setError('Failed to load project data')
|
||||||
|
setLoadingSource(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowchartData = data.flowchart_data || {}
|
||||||
|
const chars: Character[] = flowchartData.characters || []
|
||||||
|
const vars: Variable[] = flowchartData.variables || []
|
||||||
|
|
||||||
|
setSourceCharacters(chars)
|
||||||
|
setSourceVariables(vars)
|
||||||
|
setLoadingSource(false)
|
||||||
|
|
||||||
|
// Select all by default
|
||||||
|
const items = mode === 'characters' ? chars : vars
|
||||||
|
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleItem = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
const items = mode === 'characters' ? sourceCharacters : sourceVariables
|
||||||
|
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectNone = () => {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
const importWarnings: string[] = []
|
||||||
|
|
||||||
|
if (mode === 'characters') {
|
||||||
|
const selectedCharacters = sourceCharacters.filter((c) => selectedIds.has(c.id))
|
||||||
|
const existingNames = new Set(existingCharacters.map((c) => c.name.toLowerCase()))
|
||||||
|
const toImport: Character[] = []
|
||||||
|
|
||||||
|
for (const char of selectedCharacters) {
|
||||||
|
if (existingNames.has(char.name.toLowerCase())) {
|
||||||
|
importWarnings.push(`Skipped "${char.name}" (already exists)`)
|
||||||
|
} else {
|
||||||
|
// Create new ID to avoid conflicts
|
||||||
|
toImport.push({ ...char, id: nanoid() })
|
||||||
|
existingNames.add(char.name.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importWarnings.length > 0) {
|
||||||
|
setWarnings(importWarnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toImport.length > 0) {
|
||||||
|
onImportCharacters(toImport)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importWarnings.length === 0) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const selectedVariables = sourceVariables.filter((v) => selectedIds.has(v.id))
|
||||||
|
const existingNames = new Set(existingVariables.map((v) => v.name.toLowerCase()))
|
||||||
|
const toImport: Variable[] = []
|
||||||
|
|
||||||
|
for (const variable of selectedVariables) {
|
||||||
|
if (existingNames.has(variable.name.toLowerCase())) {
|
||||||
|
importWarnings.push(`Skipped "${variable.name}" (already exists)`)
|
||||||
|
} else {
|
||||||
|
// Create new ID to avoid conflicts
|
||||||
|
toImport.push({ ...variable, id: nanoid() })
|
||||||
|
existingNames.add(variable.name.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importWarnings.length > 0) {
|
||||||
|
setWarnings(importWarnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toImport.length > 0) {
|
||||||
|
onImportVariables(toImport)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importWarnings.length === 0) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = mode === 'characters' ? sourceCharacters : sourceVariables
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Import {mode === 'characters' ? 'Characters' : 'Variables'} from Project
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{loading && (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Loading projects...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="py-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !selectedProjectId && (
|
||||||
|
<>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
No other projects found.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="mb-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Select a project to import from:
|
||||||
|
</p>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => handleSelectProject(project.id)}
|
||||||
|
className="flex w-full items-center rounded-lg border border-zinc-200 px-4 py-3 text-left text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-700/50"
|
||||||
|
>
|
||||||
|
<svg className="mr-3 h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
{project.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingSource && (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Loading {mode}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedProjectId && !loadingSource && (
|
||||||
|
<>
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedProjectId(null); setWarnings([]) }}
|
||||||
|
className="mb-3 flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to projects
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
This project has no {mode} defined.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Select all/none controls */}
|
||||||
|
<div className="mb-3 flex items-center gap-3">
|
||||||
|
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{selectedIds.size} of {items.length} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSelectNone}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Select none
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item list with checkboxes */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{mode === 'characters'
|
||||||
|
? sourceCharacters.map((char) => (
|
||||||
|
<label
|
||||||
|
key={char.id}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(char.id)}
|
||||||
|
onChange={() => handleToggleItem(char.id)}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-3.5 w-3.5 rounded-full"
|
||||||
|
style={{ backgroundColor: char.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-zinc-900 dark:text-zinc-50">
|
||||||
|
{char.name}
|
||||||
|
</span>
|
||||||
|
{char.description && (
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
- {char.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
: sourceVariables.map((variable) => (
|
||||||
|
<label
|
||||||
|
key={variable.id}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(variable.id)}
|
||||||
|
onChange={() => handleToggleItem(variable.id)}
|
||||||
|
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||||
|
variable.type === 'numeric'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: variable.type === 'string'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
|
}`}>
|
||||||
|
{variable.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-zinc-900 dark:text-zinc-50">
|
||||||
|
{variable.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
(initial: {String(variable.initialValue)})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="mt-3 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
|
||||||
|
<p className="mb-1 text-xs font-medium text-orange-700 dark:text-orange-300">
|
||||||
|
Import warnings:
|
||||||
|
</p>
|
||||||
|
{warnings.map((warning, i) => (
|
||||||
|
<p key={i} className="text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer with import button */}
|
||||||
|
{selectedProjectId && !loadingSource && items.length > 0 && (
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
Import {selectedIds.size > 0 ? `(${selectedIds.size})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import Combobox from '@/components/editor/Combobox'
|
||||||
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||||
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
import type { Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type OptionConditionEditorProps = {
|
||||||
|
condition: Condition | undefined
|
||||||
|
onChange: (condition: Condition | undefined) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OptionConditionEditor({
|
||||||
|
condition,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}: OptionConditionEditorProps) {
|
||||||
|
const { variables, onAddVariable } = useEditorContext()
|
||||||
|
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||||
|
|
||||||
|
const variableItems: ComboboxItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
variables.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
label: v.name,
|
||||||
|
badge: v.type,
|
||||||
|
})),
|
||||||
|
[variables]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedVariable = useMemo(() => {
|
||||||
|
if (!condition?.variableId) return undefined
|
||||||
|
return variables.find((v) => v.id === condition.variableId)
|
||||||
|
}, [condition?.variableId, variables])
|
||||||
|
|
||||||
|
const hasInvalidReference = useMemo(() => {
|
||||||
|
if (!condition?.variableId) return false
|
||||||
|
return !variables.some((v) => v.id === condition.variableId)
|
||||||
|
}, [condition?.variableId, variables])
|
||||||
|
|
||||||
|
const availableOperators = useMemo(() => {
|
||||||
|
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
||||||
|
return [
|
||||||
|
{ value: '==', label: '==' },
|
||||||
|
{ value: '!=', label: '!=' },
|
||||||
|
{ value: '>', label: '>' },
|
||||||
|
{ value: '<', label: '<' },
|
||||||
|
{ value: '>=', label: '>=' },
|
||||||
|
{ value: '<=', label: '<=' },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: '==', label: '==' },
|
||||||
|
{ value: '!=', label: '!=' },
|
||||||
|
] as const
|
||||||
|
}, [selectedVariable])
|
||||||
|
|
||||||
|
const handleVariableSelect = useCallback(
|
||||||
|
(variableId: string) => {
|
||||||
|
const variable = variables.find((v) => v.id === variableId)
|
||||||
|
const defaultValue = variable
|
||||||
|
? variable.type === 'numeric'
|
||||||
|
? 0
|
||||||
|
: variable.type === 'boolean'
|
||||||
|
? false
|
||||||
|
: ''
|
||||||
|
: 0
|
||||||
|
const validOperator =
|
||||||
|
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
||||||
|
? '=='
|
||||||
|
: condition?.operator || '=='
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
variableName: variable?.name || '',
|
||||||
|
variableId,
|
||||||
|
operator: validOperator as Condition['operator'],
|
||||||
|
value: defaultValue,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[variables, condition?.operator, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOperatorChange = useCallback(
|
||||||
|
(operator: string) => {
|
||||||
|
if (!condition) return
|
||||||
|
onChange({
|
||||||
|
...condition,
|
||||||
|
operator: operator as Condition['operator'],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[condition, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(value: number | string | boolean) => {
|
||||||
|
if (!condition) return
|
||||||
|
onChange({
|
||||||
|
...condition,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[condition, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemoveCondition = useCallback(() => {
|
||||||
|
onChange(undefined)
|
||||||
|
onClose()
|
||||||
|
}, [onChange, onClose])
|
||||||
|
|
||||||
|
const handleAddNew = useCallback(() => {
|
||||||
|
setShowAddForm(true)
|
||||||
|
setNewName('')
|
||||||
|
setNewType('numeric')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmitNew = useCallback(() => {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||||
|
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||||
|
onChange({
|
||||||
|
variableName: newName.trim(),
|
||||||
|
variableId: newId,
|
||||||
|
operator: '==',
|
||||||
|
value: defaultValue,
|
||||||
|
})
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [newName, newType, onAddVariable, onChange])
|
||||||
|
|
||||||
|
const handleCancelNew = useCallback(() => {
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderValueInput = () => {
|
||||||
|
const varType = selectedVariable?.type || 'numeric'
|
||||||
|
|
||||||
|
if (varType === 'boolean') {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={String(condition?.value ?? false)}
|
||||||
|
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (varType === 'string') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={String(condition?.value ?? '')}
|
||||||
|
onChange={(e) => handleValueChange(e.target.value)}
|
||||||
|
placeholder="Value..."
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={typeof condition?.value === 'number' ? condition.value : 0}
|
||||||
|
onChange={(e) => handleValueChange(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||||
|
Option Condition
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variable selector */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
Variable
|
||||||
|
</label>
|
||||||
|
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
||||||
|
<Combobox
|
||||||
|
items={variableItems}
|
||||||
|
value={condition?.variableId}
|
||||||
|
onChange={handleVariableSelect}
|
||||||
|
placeholder="Select variable..."
|
||||||
|
onAddNew={handleAddNew}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasInvalidReference && (
|
||||||
|
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Variable not found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline add form */}
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
||||||
|
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
New variable
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Name"
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSubmitNew()
|
||||||
|
if (e.key === 'Escape') handleCancelNew()
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
|
<select
|
||||||
|
value={newType}
|
||||||
|
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||||
|
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="numeric">numeric</option>
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="boolean">boolean</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleCancelNew}
|
||||||
|
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitNew}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Operator and value (shown when variable is selected) */}
|
||||||
|
{condition?.variableId && (
|
||||||
|
<>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
Operator
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={condition.operator || '=='}
|
||||||
|
onChange={(e) => handleOperatorChange(e.target.value)}
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{availableOperators.map((op) => (
|
||||||
|
<option key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
{renderValueInput()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{condition?.variableId ? (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveCondition}
|
||||||
|
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
Remove condition
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,763 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import type { Character, Variable } from '@/types/flowchart'
|
||||||
|
import ImportFromProjectModal from './ImportFromProjectModal'
|
||||||
|
|
||||||
|
type Tab = 'characters' | 'variables'
|
||||||
|
|
||||||
|
type ImportModalState = { open: boolean; mode: 'characters' | 'variables' }
|
||||||
|
|
||||||
|
type ProjectSettingsModalProps = {
|
||||||
|
projectId: string
|
||||||
|
characters: Character[]
|
||||||
|
variables: Variable[]
|
||||||
|
onCharactersChange: (characters: Character[]) => void
|
||||||
|
onVariablesChange: (variables: Variable[]) => void
|
||||||
|
onClose: () => void
|
||||||
|
getCharacterUsageCount: (characterId: string) => number
|
||||||
|
getVariableUsageCount: (variableId: string) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHexColor(): string {
|
||||||
|
const colors = [
|
||||||
|
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
|
||||||
|
'#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7',
|
||||||
|
'#ec4899', '#f43f5e',
|
||||||
|
]
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectSettingsModal({
|
||||||
|
projectId,
|
||||||
|
characters,
|
||||||
|
variables,
|
||||||
|
onCharactersChange,
|
||||||
|
onVariablesChange,
|
||||||
|
onClose,
|
||||||
|
getCharacterUsageCount,
|
||||||
|
getVariableUsageCount,
|
||||||
|
}: ProjectSettingsModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('characters')
|
||||||
|
const [importModal, setImportModal] = useState<ImportModalState>({ open: false, mode: 'characters' })
|
||||||
|
|
||||||
|
const handleImportCharacters = (imported: Character[]) => {
|
||||||
|
onCharactersChange([...characters, ...imported])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportVariables = (imported: Variable[]) => {
|
||||||
|
onVariablesChange([...variables, ...imported])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="relative flex h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Project Settings
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-zinc-200 px-6 dark:border-zinc-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('characters')}
|
||||||
|
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'characters'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Characters
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('variables')}
|
||||||
|
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'variables'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Variables
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{activeTab === 'characters' && (
|
||||||
|
<CharactersTab
|
||||||
|
characters={characters}
|
||||||
|
onChange={onCharactersChange}
|
||||||
|
getUsageCount={getCharacterUsageCount}
|
||||||
|
onImport={() => setImportModal({ open: true, mode: 'characters' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'variables' && (
|
||||||
|
<VariablesTab
|
||||||
|
variables={variables}
|
||||||
|
onChange={onVariablesChange}
|
||||||
|
getUsageCount={getVariableUsageCount}
|
||||||
|
onImport={() => setImportModal({ open: true, mode: 'variables' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importModal.open && (
|
||||||
|
<ImportFromProjectModal
|
||||||
|
mode={importModal.mode}
|
||||||
|
currentProjectId={projectId}
|
||||||
|
existingCharacters={characters}
|
||||||
|
existingVariables={variables}
|
||||||
|
onImportCharacters={handleImportCharacters}
|
||||||
|
onImportVariables={handleImportVariables}
|
||||||
|
onClose={() => setImportModal({ open: false, mode: importModal.mode })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Characters Tab
|
||||||
|
type CharactersTabProps = {
|
||||||
|
characters: Character[]
|
||||||
|
onChange: (characters: Character[]) => void
|
||||||
|
getUsageCount: (characterId: string) => number
|
||||||
|
onImport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CharacterFormData = {
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function CharactersTab({ characters, onChange, getUsageCount, onImport }: CharactersTabProps) {
|
||||||
|
const [isAdding, setIsAdding] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<CharacterFormData>({ name: '', color: randomHexColor(), description: '' })
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({ name: '', color: randomHexColor(), description: '' })
|
||||||
|
setFormError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateName = (name: string, excludeId?: string): boolean => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setFormError('Name is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const duplicate = characters.find(
|
||||||
|
(c) => c.name.toLowerCase() === name.trim().toLowerCase() && c.id !== excludeId
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
setFormError('A character with this name already exists')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setFormError(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!validateName(formData.name)) return
|
||||||
|
const newCharacter: Character = {
|
||||||
|
id: nanoid(),
|
||||||
|
name: formData.name.trim(),
|
||||||
|
color: formData.color,
|
||||||
|
description: formData.description.trim() || undefined,
|
||||||
|
}
|
||||||
|
onChange([...characters, newCharacter])
|
||||||
|
setIsAdding(false)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (character: Character) => {
|
||||||
|
setEditingId(character.id)
|
||||||
|
setFormData({
|
||||||
|
name: character.name,
|
||||||
|
color: character.color,
|
||||||
|
description: character.description || '',
|
||||||
|
})
|
||||||
|
setFormError(null)
|
||||||
|
setIsAdding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (!editingId) return
|
||||||
|
if (!validateName(formData.name, editingId)) return
|
||||||
|
onChange(
|
||||||
|
characters.map((c) =>
|
||||||
|
c.id === editingId
|
||||||
|
? { ...c, name: formData.name.trim(), color: formData.color, description: formData.description.trim() || undefined }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setEditingId(null)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
const usageCount = getUsageCount(id)
|
||||||
|
if (usageCount > 0 && deleteConfirm !== id) {
|
||||||
|
setDeleteConfirm(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(characters.filter((c) => c.id !== id))
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelForm = () => {
|
||||||
|
setIsAdding(false)
|
||||||
|
setEditingId(null)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Define characters that can be referenced in dialogue nodes.
|
||||||
|
</p>
|
||||||
|
{!isAdding && !editingId && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onImport}
|
||||||
|
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Import from project
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||||
|
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
Add Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Character List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{characters.map((character) => (
|
||||||
|
<div key={character.id}>
|
||||||
|
{editingId === character.id ? (
|
||||||
|
<CharacterForm
|
||||||
|
formData={formData}
|
||||||
|
formError={formError}
|
||||||
|
onChange={setFormData}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
onCancel={handleCancelForm}
|
||||||
|
saveLabel="Save"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full"
|
||||||
|
style={{ backgroundColor: character.color }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
||||||
|
{character.name}
|
||||||
|
</span>
|
||||||
|
{character.description && (
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{character.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{deleteConfirm === character.id && (
|
||||||
|
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Used in {getUsageCount(character.id)} node(s). Delete anyway?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(character)}
|
||||||
|
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(character.id)}
|
||||||
|
className={`rounded px-2 py-1 text-sm ${
|
||||||
|
deleteConfirm === character.id
|
||||||
|
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
|
||||||
|
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{characters.length === 0 && !isAdding && (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
No characters defined yet. Click "Add Character" to create one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Form */}
|
||||||
|
{isAdding && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<CharacterForm
|
||||||
|
formData={formData}
|
||||||
|
formError={formError}
|
||||||
|
onChange={setFormData}
|
||||||
|
onSave={handleAdd}
|
||||||
|
onCancel={handleCancelForm}
|
||||||
|
saveLabel="Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character Form
|
||||||
|
type CharacterFormProps = {
|
||||||
|
formData: CharacterFormData
|
||||||
|
formError: string | null
|
||||||
|
onChange: (data: CharacterFormData) => void
|
||||||
|
onSave: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
saveLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: CharacterFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(e) => onChange({ ...formData, color: e.target.value })}
|
||||||
|
className="h-8 w-8 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => onChange({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Character name"
|
||||||
|
autoFocus
|
||||||
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => onChange({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="rounded-md bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables Tab
|
||||||
|
type VariablesTabProps = {
|
||||||
|
variables: Variable[]
|
||||||
|
onChange: (variables: Variable[]) => void
|
||||||
|
getUsageCount: (variableId: string) => number
|
||||||
|
onImport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariableType = 'numeric' | 'string' | 'boolean'
|
||||||
|
|
||||||
|
type VariableFormData = {
|
||||||
|
name: string
|
||||||
|
type: VariableType
|
||||||
|
initialValue: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultInitialValues: Record<VariableType, string> = {
|
||||||
|
numeric: '0',
|
||||||
|
string: '',
|
||||||
|
boolean: 'false',
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInitialValue(type: VariableType, raw: string): number | string | boolean {
|
||||||
|
switch (type) {
|
||||||
|
case 'numeric':
|
||||||
|
return Number(raw) || 0
|
||||||
|
case 'boolean':
|
||||||
|
return raw === 'true'
|
||||||
|
case 'string':
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariablesTab({ variables, onChange, getUsageCount, onImport }: VariablesTabProps) {
|
||||||
|
const [isAdding, setIsAdding] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formData, setFormData] = useState<VariableFormData>({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
||||||
|
setFormError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateName = (name: string, excludeId?: string): boolean => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setFormError('Name is required')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const duplicate = variables.find(
|
||||||
|
(v) => v.name.toLowerCase() === name.trim().toLowerCase() && v.id !== excludeId
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
setFormError('A variable with this name already exists')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setFormError(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!validateName(formData.name)) return
|
||||||
|
const newVariable: Variable = {
|
||||||
|
id: nanoid(),
|
||||||
|
name: formData.name.trim(),
|
||||||
|
type: formData.type,
|
||||||
|
initialValue: parseInitialValue(formData.type, formData.initialValue),
|
||||||
|
description: formData.description.trim() || undefined,
|
||||||
|
}
|
||||||
|
onChange([...variables, newVariable])
|
||||||
|
setIsAdding(false)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (variable: Variable) => {
|
||||||
|
setEditingId(variable.id)
|
||||||
|
setFormData({
|
||||||
|
name: variable.name,
|
||||||
|
type: variable.type,
|
||||||
|
initialValue: String(variable.initialValue),
|
||||||
|
description: variable.description || '',
|
||||||
|
})
|
||||||
|
setFormError(null)
|
||||||
|
setIsAdding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (!editingId) return
|
||||||
|
if (!validateName(formData.name, editingId)) return
|
||||||
|
onChange(
|
||||||
|
variables.map((v) =>
|
||||||
|
v.id === editingId
|
||||||
|
? {
|
||||||
|
...v,
|
||||||
|
name: formData.name.trim(),
|
||||||
|
type: formData.type,
|
||||||
|
initialValue: parseInitialValue(formData.type, formData.initialValue),
|
||||||
|
description: formData.description.trim() || undefined,
|
||||||
|
}
|
||||||
|
: v
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setEditingId(null)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
const usageCount = getUsageCount(id)
|
||||||
|
if (usageCount > 0 && deleteConfirm !== id) {
|
||||||
|
setDeleteConfirm(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(variables.filter((v) => v.id !== id))
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelForm = () => {
|
||||||
|
setIsAdding(false)
|
||||||
|
setEditingId(null)
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Define variables that can be referenced in variable nodes and edge conditions.
|
||||||
|
</p>
|
||||||
|
{!isAdding && !editingId && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onImport}
|
||||||
|
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Import from project
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||||
|
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
Add Variable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variable List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<div key={variable.id}>
|
||||||
|
{editingId === variable.id ? (
|
||||||
|
<VariableForm
|
||||||
|
formData={formData}
|
||||||
|
formError={formError}
|
||||||
|
onChange={setFormData}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
onCancel={handleCancelForm}
|
||||||
|
saveLabel="Save"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||||
|
variable.type === 'numeric'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: variable.type === 'string'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
|
}`}>
|
||||||
|
{variable.type}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
||||||
|
{variable.name}
|
||||||
|
</span>
|
||||||
|
{variable.description && (
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{variable.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
Initial: {String(variable.initialValue)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{deleteConfirm === variable.id && (
|
||||||
|
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Used in {getUsageCount(variable.id)} node(s). Delete anyway?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(variable)}
|
||||||
|
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(variable.id)}
|
||||||
|
className={`rounded px-2 py-1 text-sm ${
|
||||||
|
deleteConfirm === variable.id
|
||||||
|
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
|
||||||
|
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{variables.length === 0 && !isAdding && (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
No variables defined yet. Click "Add Variable" to create one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Form */}
|
||||||
|
{isAdding && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<VariableForm
|
||||||
|
formData={formData}
|
||||||
|
formError={formError}
|
||||||
|
onChange={setFormData}
|
||||||
|
onSave={handleAdd}
|
||||||
|
onCancel={handleCancelForm}
|
||||||
|
saveLabel="Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable Form
|
||||||
|
type VariableFormProps = {
|
||||||
|
formData: VariableFormData
|
||||||
|
formError: string | null
|
||||||
|
onChange: (data: VariableFormData) => void
|
||||||
|
onSave: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
saveLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariableForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: VariableFormProps) {
|
||||||
|
const handleTypeChange = (newType: VariableType) => {
|
||||||
|
onChange({ ...formData, type: newType, initialValue: defaultInitialValues[newType] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => onChange({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Variable name"
|
||||||
|
autoFocus
|
||||||
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => handleTypeChange(e.target.value as VariableType)}
|
||||||
|
className="block rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
<option value="numeric">Numeric</option>
|
||||||
|
<option value="string">String</option>
|
||||||
|
<option value="boolean">Boolean</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Initial Value *
|
||||||
|
</label>
|
||||||
|
{formData.type === 'boolean' ? (
|
||||||
|
<select
|
||||||
|
value={formData.initialValue}
|
||||||
|
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
||||||
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
<option value="false">false</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
</select>
|
||||||
|
) : formData.type === 'numeric' ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.initialValue}
|
||||||
|
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
||||||
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.initialValue}
|
||||||
|
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
||||||
|
placeholder="Initial value"
|
||||||
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => onChange({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="rounded-md bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ type ToolbarProps = {
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
|
onProjectSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Toolbar({
|
export default function Toolbar({
|
||||||
|
|
@ -16,6 +17,7 @@ export default function Toolbar({
|
||||||
onSave,
|
onSave,
|
||||||
onExport,
|
onExport,
|
||||||
onImport,
|
onImport,
|
||||||
|
onProjectSettings,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
|
@ -44,6 +46,12 @@ export default function Toolbar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onProjectSettings}
|
||||||
|
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
Project Settings
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, ChangeEvent } from 'react'
|
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||||||
|
import type { Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
type ChoiceOption = {
|
type ChoiceOption = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
condition?: Condition
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChoiceNodeData = {
|
type ChoiceNodeData = {
|
||||||
|
|
@ -19,6 +23,8 @@ const MAX_OPTIONS = 6
|
||||||
|
|
||||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
|
const { variables } = useEditorContext()
|
||||||
|
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||||
|
|
||||||
const updatePrompt = useCallback(
|
const updatePrompt = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -54,6 +60,27 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const updateOptionCondition = useCallback(
|
||||||
|
(optionId: string, condition: Condition | undefined) => {
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((node) =>
|
||||||
|
node.id === id
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
options: node.data.options.map((opt: ChoiceOption) =>
|
||||||
|
opt.id === optionId ? { ...opt, condition } : opt
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: node
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[id, setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
const addOption = useCallback(() => {
|
const addOption = useCallback(() => {
|
||||||
if (data.options.length >= MAX_OPTIONS) return
|
if (data.options.length >= MAX_OPTIONS) return
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
|
|
@ -96,68 +123,118 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
[id, data.options.length, setNodes]
|
[id, data.options.length, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const editingOption = useMemo(() => {
|
||||||
|
if (!editingConditionOptionId) return null
|
||||||
|
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
||||||
|
}, [editingConditionOptionId, data.options])
|
||||||
|
|
||||||
|
const hasInvalidConditionReference = useCallback(
|
||||||
|
(option: ChoiceOption) => {
|
||||||
|
if (!option.condition?.variableId) return false
|
||||||
|
return !variables.some((v) => v.id === option.condition!.variableId)
|
||||||
|
},
|
||||||
|
[variables]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
<>
|
||||||
<Handle
|
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
||||||
type="target"
|
<Handle
|
||||||
position={Position.Top}
|
type="target"
|
||||||
id="input"
|
position={Position.Top}
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
id="input"
|
||||||
/>
|
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
||||||
Choice
|
Choice
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.prompt || ''}
|
||||||
|
onChange={updatePrompt}
|
||||||
|
placeholder="What do you choose?"
|
||||||
|
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.options.map((option, index) => (
|
||||||
|
<div key={option.id}>
|
||||||
|
<div className="relative flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={option.label}
|
||||||
|
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingConditionOptionId(option.id)}
|
||||||
|
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||||
|
option.condition?.variableId
|
||||||
|
? hasInvalidConditionReference(option)
|
||||||
|
? 'bg-orange-100 text-orange-600 ring-1 ring-orange-500 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||||
|
}`}
|
||||||
|
title={option.condition?.variableId ? 'Edit condition' : 'Add condition'}
|
||||||
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(option.id)}
|
||||||
|
disabled={data.options.length <= MIN_OPTIONS}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||||
|
title="Remove option"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id={`option-${index}`}
|
||||||
|
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||||
|
style={{
|
||||||
|
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{option.condition?.variableId && (
|
||||||
|
<div className={`mt-0.5 ml-1 text-[10px] ${
|
||||||
|
hasInvalidConditionReference(option)
|
||||||
|
? 'text-orange-500 dark:text-orange-400'
|
||||||
|
: 'text-zinc-500 dark:text-zinc-400'
|
||||||
|
}`}>
|
||||||
|
if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addOption}
|
||||||
|
disabled={data.options.length >= MAX_OPTIONS}
|
||||||
|
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||||||
|
title="Add option"
|
||||||
|
>
|
||||||
|
+ Add Option
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
{editingOption && (
|
||||||
type="text"
|
<OptionConditionEditor
|
||||||
value={data.prompt || ''}
|
condition={editingOption.condition}
|
||||||
onChange={updatePrompt}
|
onChange={(condition) => updateOptionCondition(editingOption.id, condition)}
|
||||||
placeholder="What do you choose?"
|
onClose={() => setEditingConditionOptionId(null)}
|
||||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
/>
|
||||||
/>
|
)}
|
||||||
|
</>
|
||||||
<div className="space-y-2">
|
|
||||||
{data.options.map((option, index) => (
|
|
||||||
<div key={option.id} className="relative flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={option.label}
|
|
||||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
|
||||||
placeholder={`Option ${index + 1}`}
|
|
||||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeOption(option.id)}
|
|
||||||
disabled={data.options.length <= MIN_OPTIONS}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
|
||||||
title="Remove option"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={`option-${index}`}
|
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
|
||||||
style={{
|
|
||||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addOption}
|
|
||||||
disabled={data.options.length >= MAX_OPTIONS}
|
|
||||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
|
||||||
title="Add option"
|
|
||||||
>
|
|
||||||
+ Add Option
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,56 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, ChangeEvent } from 'react'
|
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||||
|
import Combobox from '@/components/editor/Combobox'
|
||||||
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||||
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
|
||||||
type DialogueNodeData = {
|
type DialogueNodeData = {
|
||||||
speaker?: string
|
speaker?: string
|
||||||
|
characterId?: string
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RANDOM_COLORS = [
|
||||||
|
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||||
|
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||||
|
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||||
|
]
|
||||||
|
|
||||||
|
function randomColor(): string {
|
||||||
|
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
||||||
|
}
|
||||||
|
|
||||||
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
|
const { characters, onAddCharacter } = useEditorContext()
|
||||||
|
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newColor, setNewColor] = useState(randomColor)
|
||||||
|
|
||||||
|
const characterItems: ComboboxItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
characters.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.name,
|
||||||
|
color: c.color,
|
||||||
|
})),
|
||||||
|
[characters]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasInvalidReference = useMemo(() => {
|
||||||
|
if (!data.characterId) return false
|
||||||
|
return !characters.some((c) => c.id === data.characterId)
|
||||||
|
}, [data.characterId, characters])
|
||||||
|
|
||||||
const updateNodeData = useCallback(
|
const updateNodeData = useCallback(
|
||||||
(field: keyof DialogueNodeData, value: string) => {
|
(updates: Partial<DialogueNodeData>) => {
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
nodes.map((node) =>
|
nodes.map((node) =>
|
||||||
node.id === id
|
node.id === id
|
||||||
? { ...node, data: { ...node.data, [field]: value } }
|
? { ...node, data: { ...node.data, ...updates } }
|
||||||
: node
|
: node
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -24,22 +58,52 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSpeakerChange = useCallback(
|
const handleCharacterSelect = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(characterId: string) => {
|
||||||
updateNodeData('speaker', e.target.value)
|
const character = characters.find((c) => c.id === characterId)
|
||||||
|
updateNodeData({
|
||||||
|
characterId,
|
||||||
|
speaker: character?.name || '',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[updateNodeData]
|
[characters, updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleAddNew = useCallback(() => {
|
||||||
|
setShowAddForm(true)
|
||||||
|
setNewName('')
|
||||||
|
setNewColor(randomColor())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmitNew = useCallback(() => {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
const newId = onAddCharacter(newName.trim(), newColor)
|
||||||
|
updateNodeData({
|
||||||
|
characterId: newId,
|
||||||
|
speaker: newName.trim(),
|
||||||
|
})
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [newName, newColor, onAddCharacter, updateNodeData])
|
||||||
|
|
||||||
|
const handleCancelNew = useCallback(() => {
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleTextChange = useCallback(
|
const handleTextChange = useCallback(
|
||||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
updateNodeData('text', e.target.value)
|
updateNodeData({ text: e.target.value })
|
||||||
},
|
},
|
||||||
[updateNodeData]
|
[updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[200px] rounded-lg border-2 border-blue-500 bg-blue-50 p-3 shadow-md dark:border-blue-400 dark:bg-blue-950">
|
<div
|
||||||
|
className={`min-w-[200px] rounded-lg border-2 ${
|
||||||
|
hasInvalidReference
|
||||||
|
? 'border-orange-500 dark:border-orange-400'
|
||||||
|
: 'border-blue-500 dark:border-blue-400'
|
||||||
|
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
||||||
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
|
|
@ -51,13 +115,63 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
||||||
Dialogue
|
Dialogue
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<div className="mb-2">
|
||||||
type="text"
|
<Combobox
|
||||||
value={data.speaker || ''}
|
items={characterItems}
|
||||||
onChange={handleSpeakerChange}
|
value={data.characterId}
|
||||||
placeholder="Speaker"
|
onChange={handleCharacterSelect}
|
||||||
className="mb-2 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
placeholder="Select speaker..."
|
||||||
/>
|
onAddNew={handleAddNew}
|
||||||
|
/>
|
||||||
|
{hasInvalidReference && (
|
||||||
|
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Character not found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="mb-2 rounded border border-blue-300 bg-white p-2 dark:border-blue-600 dark:bg-zinc-800">
|
||||||
|
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
New character
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newColor}
|
||||||
|
onChange={(e) => setNewColor(e.target.value)}
|
||||||
|
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Name"
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSubmitNew()
|
||||||
|
if (e.key === 'Escape') handleCancelNew()
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleCancelNew}
|
||||||
|
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitNew}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={data.text || ''}
|
value={data.text || ''}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,52 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, ChangeEvent } from 'react'
|
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||||
|
import Combobox from '@/components/editor/Combobox'
|
||||||
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||||
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
|
||||||
type VariableNodeData = {
|
type VariableNodeData = {
|
||||||
variableName: string
|
variableName: string
|
||||||
|
variableId?: string
|
||||||
operation: 'set' | 'add' | 'subtract'
|
operation: 'set' | 'add' | 'subtract'
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
|
const { variables, onAddVariable } = useEditorContext()
|
||||||
|
|
||||||
const updateVariableName = useCallback(
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||||
|
|
||||||
|
const variableItems: ComboboxItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
variables.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
label: v.name,
|
||||||
|
badge: v.type,
|
||||||
|
})),
|
||||||
|
[variables]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedVariable = useMemo(() => {
|
||||||
|
if (!data.variableId) return undefined
|
||||||
|
return variables.find((v) => v.id === data.variableId)
|
||||||
|
}, [data.variableId, variables])
|
||||||
|
|
||||||
|
const hasInvalidReference = useMemo(() => {
|
||||||
|
if (!data.variableId) return false
|
||||||
|
return !variables.some((v) => v.id === data.variableId)
|
||||||
|
}, [data.variableId, variables])
|
||||||
|
|
||||||
|
const updateNodeData = useCallback(
|
||||||
|
(updates: Partial<VariableNodeData>) => {
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
nodes.map((node) =>
|
nodes.map((node) =>
|
||||||
node.id === id
|
node.id === id
|
||||||
? { ...node, data: { ...node.data, variableName: e.target.value } }
|
? { ...node, data: { ...node.data, ...updates } }
|
||||||
: node
|
: node
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -25,35 +54,69 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleVariableSelect = useCallback(
|
||||||
|
(variableId: string) => {
|
||||||
|
const variable = variables.find((v) => v.id === variableId)
|
||||||
|
const updates: Partial<VariableNodeData> = {
|
||||||
|
variableId,
|
||||||
|
variableName: variable?.name || '',
|
||||||
|
}
|
||||||
|
// Reset operation to 'set' if current operation is not valid for the variable's type
|
||||||
|
if (variable && variable.type !== 'numeric' && (data.operation === 'add' || data.operation === 'subtract')) {
|
||||||
|
updates.operation = 'set'
|
||||||
|
}
|
||||||
|
updateNodeData(updates)
|
||||||
|
},
|
||||||
|
[variables, data.operation, updateNodeData]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAddNew = useCallback(() => {
|
||||||
|
setShowAddForm(true)
|
||||||
|
setNewName('')
|
||||||
|
setNewType('numeric')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmitNew = useCallback(() => {
|
||||||
|
if (!newName.trim()) return
|
||||||
|
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||||
|
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||||
|
updateNodeData({
|
||||||
|
variableId: newId,
|
||||||
|
variableName: newName.trim(),
|
||||||
|
})
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [newName, newType, onAddVariable, updateNodeData])
|
||||||
|
|
||||||
|
const handleCancelNew = useCallback(() => {
|
||||||
|
setShowAddForm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const updateOperation = useCallback(
|
const updateOperation = useCallback(
|
||||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
setNodes((nodes) =>
|
updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' })
|
||||||
nodes.map((node) =>
|
|
||||||
node.id === id
|
|
||||||
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
|
|
||||||
: node
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[id, setNodes]
|
[updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateValue = useCallback(
|
const updateValue = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = parseFloat(e.target.value) || 0
|
const value = parseFloat(e.target.value) || 0
|
||||||
setNodes((nodes) =>
|
updateNodeData({ value })
|
||||||
nodes.map((node) =>
|
|
||||||
node.id === id
|
|
||||||
? { ...node, data: { ...node.data, value } }
|
|
||||||
: node
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[id, setNodes]
|
[updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Filter operations based on selected variable type
|
||||||
|
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-[200px] rounded-lg border-2 border-orange-500 bg-orange-50 p-3 shadow-md dark:border-orange-400 dark:bg-orange-950">
|
<div
|
||||||
|
className={`min-w-[200px] rounded-lg border-2 ${
|
||||||
|
hasInvalidReference
|
||||||
|
? 'border-orange-500 dark:border-orange-400'
|
||||||
|
: 'border-orange-500 dark:border-orange-400'
|
||||||
|
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
||||||
|
>
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
|
|
@ -65,13 +128,68 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||||
Variable
|
Variable
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<div className={`mb-2 ${hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}`}>
|
||||||
type="text"
|
<Combobox
|
||||||
value={data.variableName || ''}
|
items={variableItems}
|
||||||
onChange={updateVariableName}
|
value={data.variableId}
|
||||||
placeholder="variableName"
|
onChange={handleVariableSelect}
|
||||||
className="mb-2 w-full rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
placeholder="Select variable..."
|
||||||
/>
|
onAddNew={handleAddNew}
|
||||||
|
/>
|
||||||
|
{hasInvalidReference && (
|
||||||
|
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||||
|
Variable not found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="mb-2 rounded border border-orange-300 bg-white p-2 dark:border-orange-600 dark:bg-zinc-800">
|
||||||
|
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||||
|
New variable
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Name"
|
||||||
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSubmitNew()
|
||||||
|
if (e.key === 'Escape') handleCancelNew()
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
|
<select
|
||||||
|
value={newType}
|
||||||
|
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||||
|
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="numeric">numeric</option>
|
||||||
|
<option value="string">string</option>
|
||||||
|
<option value="boolean">boolean</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleCancelNew}
|
||||||
|
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitNew}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="rounded bg-orange-600 px-2 py-0.5 text-xs text-white hover:bg-orange-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-2 flex gap-2">
|
<div className="mb-2 flex gap-2">
|
||||||
<select
|
<select
|
||||||
|
|
@ -80,8 +198,8 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||||
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
|
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
|
||||||
>
|
>
|
||||||
<option value="set">set</option>
|
<option value="set">set</option>
|
||||||
<option value="add">add</option>
|
{isNumeric && <option value="add">add</option>}
|
||||||
<option value="subtract">subtract</option>
|
{isNumeric && <option value="subtract">subtract</option>}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,50 @@ export type Position = {
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Character type: represents a defined character in the project
|
||||||
|
export type Character = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string; // hex color
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Variable type: represents a defined variable in the project
|
||||||
|
export type Variable = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'numeric' | 'string' | 'boolean';
|
||||||
|
initialValue: number | string | boolean;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Condition type for conditional edges and choice options
|
||||||
|
export type Condition = {
|
||||||
|
variableName: string;
|
||||||
|
variableId?: string;
|
||||||
|
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||||
|
value: number | string | boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// DialogueNode type: represents character speech/dialogue
|
// DialogueNode type: represents character speech/dialogue
|
||||||
|
export type DialogueNodeData = {
|
||||||
|
speaker?: string;
|
||||||
|
characterId?: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DialogueNode = {
|
export type DialogueNode = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'dialogue';
|
type: 'dialogue';
|
||||||
position: Position;
|
position: Position;
|
||||||
data: {
|
data: DialogueNodeData;
|
||||||
speaker?: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Choice option type for ChoiceNode
|
// Choice option type for ChoiceNode
|
||||||
export type ChoiceOption = {
|
export type ChoiceOption = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
condition?: Condition;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ChoiceNode type: represents branching decisions
|
// ChoiceNode type: represents branching decisions
|
||||||
|
|
@ -33,27 +62,23 @@ export type ChoiceNode = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// VariableNode type: represents variable operations
|
// VariableNode type: represents variable operations
|
||||||
|
export type VariableNodeData = {
|
||||||
|
variableName: string;
|
||||||
|
variableId?: string;
|
||||||
|
operation: 'set' | 'add' | 'subtract';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type VariableNode = {
|
export type VariableNode = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'variable';
|
type: 'variable';
|
||||||
position: Position;
|
position: Position;
|
||||||
data: {
|
data: VariableNodeData;
|
||||||
variableName: string;
|
|
||||||
operation: 'set' | 'add' | 'subtract';
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Union type for all node types
|
// Union type for all node types
|
||||||
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
|
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
|
||||||
|
|
||||||
// Condition type for conditional edges
|
|
||||||
export type Condition = {
|
|
||||||
variableName: string;
|
|
||||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// FlowchartEdge type: represents connections between nodes
|
// FlowchartEdge type: represents connections between nodes
|
||||||
export type FlowchartEdge = {
|
export type FlowchartEdge = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -70,4 +95,6 @@ export type FlowchartEdge = {
|
||||||
export type FlowchartData = {
|
export type FlowchartData = {
|
||||||
nodes: FlowchartNode[];
|
nodes: FlowchartNode[];
|
||||||
edges: FlowchartEdge[];
|
edges: FlowchartEdge[];
|
||||||
|
characters: Character[];
|
||||||
|
variables: Variable[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- Migration: Add characters and variables arrays to flowchart_data JSONB default
|
||||||
|
-- Part of Character/Variable Management feature (US-055)
|
||||||
|
--
|
||||||
|
-- The characters and variables arrays are stored within the flowchart_data JSONB column.
|
||||||
|
-- This migration updates the default value for new projects to include empty arrays.
|
||||||
|
-- Existing projects without these fields are handled at the application layer,
|
||||||
|
-- which defaults missing characters/variables to empty arrays on read.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- UPDATE DEFAULT VALUE FOR flowchart_data
|
||||||
|
-- =============================================================================
|
||||||
|
-- Update the default to include characters and variables arrays
|
||||||
|
ALTER TABLE projects
|
||||||
|
ALTER COLUMN flowchart_data
|
||||||
|
SET DEFAULT '{"nodes": [], "edges": [], "characters": [], "variables": []}'::jsonb;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- DOCUMENTATION: JSONB Structure
|
||||||
|
-- =============================================================================
|
||||||
|
-- The flowchart_data column now expects the following top-level structure:
|
||||||
|
--
|
||||||
|
-- {
|
||||||
|
-- "nodes": [...], -- Array of flowchart nodes
|
||||||
|
-- "edges": [...], -- Array of flowchart edges
|
||||||
|
-- "characters": [ -- Array of character definitions
|
||||||
|
-- {
|
||||||
|
-- "id": "string", -- Unique identifier (nanoid)
|
||||||
|
-- "name": "string", -- Character name (required, unique per project)
|
||||||
|
-- "color": "string", -- Hex color code (e.g., "#FF5733")
|
||||||
|
-- "description": "string" -- Optional description
|
||||||
|
-- }
|
||||||
|
-- ],
|
||||||
|
-- "variables": [ -- Array of variable definitions
|
||||||
|
-- {
|
||||||
|
-- "id": "string", -- Unique identifier (nanoid)
|
||||||
|
-- "name": "string", -- Variable name (required, unique per project)
|
||||||
|
-- "type": "string", -- One of: 'numeric', 'string', 'boolean'
|
||||||
|
-- "initialValue": "any", -- Initial value matching the type
|
||||||
|
-- "description": "string" -- Optional description
|
||||||
|
-- }
|
||||||
|
-- ]
|
||||||
|
-- }
|
||||||
|
--
|
||||||
|
-- Note: Existing projects that do not have characters/variables fields
|
||||||
|
-- are handled at the application layer, which defaults them to empty arrays.
|
||||||
Loading…
Reference in New Issue