developing #10

Merged
GHMiranda merged 64 commits from developing into master 2026-01-25 00:37:11 +00:00
16 changed files with 3178 additions and 1714 deletions
Showing only changes of commit 190e25228b - Show all commits

845
prd.json

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,7 +1,11 @@
'use client'
<<<<<<< HEAD
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
=======
import React, { useCallback, useMemo, useState } from 'react'
>>>>>>> ralph/collaboration-and-character-variables
import ReactFlow, {
Background,
BackgroundVariant,
@ -28,6 +32,7 @@ import { createClient } from '@/lib/supabase/client'
import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode'
<<<<<<< HEAD
import ConditionalEdge from '@/components/editor/edges/ConditionalEdge'
import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu'
import ConditionEditor from '@/components/editor/ConditionEditor'
@ -51,11 +56,19 @@ type ConditionEditorState = {
edgeId: string
condition?: Condition
} | null
=======
import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import ConditionEditor from '@/components/editor/ConditionEditor'
import { EditorProvider } from '@/components/editor/EditorContext'
import Toast from '@/components/Toast'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
>>>>>>> ralph/collaboration-and-character-variables
type FlowchartEditorProps = {
projectId: string
projectName: string
initialData: FlowchartData
needsMigration?: boolean
}
// Convert our FlowchartNode type to React Flow Node type
@ -84,6 +97,7 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
}))
}
<<<<<<< HEAD
// Convert React Flow Node type back to our FlowchartNode type
function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] {
return nodes.map((node) => ({
@ -390,11 +404,154 @@ function convertToRenpyFormat(
projectName,
exportedAt: new Date().toISOString(),
sections,
=======
const RANDOM_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
function randomHexColor(): string {
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
}
// Compute auto-migration of existing free-text values to character/variable definitions
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
if (!shouldMigrate) {
return {
characters: initialData.characters,
variables: initialData.variables,
nodes: initialData.nodes,
edges: initialData.edges,
toastMessage: null as string | null,
}
}
// Collect unique speaker names from dialogue nodes
const speakerNames = new Set<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`,
>>>>>>> ralph/collaboration-and-character-variables
}
}
// Inner component that uses useReactFlow hook
<<<<<<< HEAD
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
=======
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
>>>>>>> ralph/collaboration-and-character-variables
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
@ -449,11 +606,73 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
return { showPrompt: false, savedDraft: null }
})
// Compute migrated data once on first render using a lazy state initializer
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
const [nodes, setNodes, onNodesChange] = useNodesState(
toReactFlowNodes(initialData.nodes)
toReactFlowNodes(migratedData.nodes)
)
const [edges, setEdges, onEdgesChange] = useEdgesState(
toReactFlowEdges(initialData.edges)
toReactFlowEdges(migratedData.edges)
)
const [characters, setCharacters] = useState<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]
)
// Track debounce timer
@ -809,400 +1028,89 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
console.log('Deleted edges:', deletedEdges.map((e) => e.id))
}, [])
// Context menu handlers
const closeContextMenu = useCallback(() => {
setContextMenu(null)
// Handle edge click to open condition editor
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
setSelectedEdgeId(edge.id)
}, [])
// Handle right-click on canvas (pane)
const onPaneContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'canvas',
})
},
[]
)
// Handle right-click on node
const onNodeContextMenu: NodeMouseHandler = useCallback(
(event, node) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'node',
nodeId: node.id,
})
},
[]
)
// Handle right-click on edge
const onEdgeContextMenu: EdgeMouseHandler = useCallback(
(event, edge) => {
event.preventDefault()
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'edge',
edgeId: edge.id,
})
},
[]
)
// Add node at specific position (for context menu)
const handleAddNodeAtPosition = useCallback(
(type: 'dialogue' | 'choice' | 'variable') => {
if (!contextMenu) return
// Convert screen position to flow position
const position = screenToFlowPosition({
x: contextMenu.x,
y: contextMenu.y,
})
let newNode: Node
if (type === 'dialogue') {
newNode = {
id: nanoid(),
type: 'dialogue',
position,
data: { speaker: '', text: '' },
}
} else if (type === 'choice') {
newNode = {
id: nanoid(),
type: 'choice',
position,
data: {
prompt: '',
options: [
{ id: nanoid(), label: '' },
{ id: nanoid(), label: '' },
],
},
}
} else {
newNode = {
id: nanoid(),
type: 'variable',
position,
data: {
variableName: '',
operation: 'set',
value: 0,
},
}
}
setNodes((nodes) => [...nodes, newNode])
},
[contextMenu, screenToFlowPosition, setNodes]
)
// Delete selected node from context menu
const handleDeleteNode = useCallback(() => {
if (!contextMenu?.nodeId) return
setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId))
}, [contextMenu, setNodes])
// Delete selected edge from context menu
const handleDeleteEdge = useCallback(() => {
if (!contextMenu?.edgeId) return
setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId))
}, [contextMenu, setEdges])
// Open condition editor for an edge
const openConditionEditor = useCallback(
(edgeId: string) => {
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return
setConditionEditor({
edgeId,
condition: edge.data?.condition,
})
},
[edges]
)
// Add condition to edge (opens ConditionEditor modal)
const handleAddCondition = useCallback(() => {
if (!contextMenu?.edgeId) return
openConditionEditor(contextMenu.edgeId)
}, [contextMenu, openConditionEditor])
// Handle double-click on edge to open condition editor
const onEdgeDoubleClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
openConditionEditor(edge.id)
},
[openConditionEditor]
)
// Save condition to edge
const handleSaveCondition = useCallback(
(edgeId: string, condition: Condition) => {
// Handle condition change from ConditionEditor
const handleConditionChange = useCallback(
(edgeId: string, condition: Condition | undefined) => {
setEdges((eds) =>
eds.map((edge) =>
edge.id === edgeId
? { ...edge, data: { ...edge.data, condition } }
? { ...edge, data: condition ? { condition } : undefined }
: edge
)
)
setConditionEditor(null)
},
[setEdges]
)
// Remove condition from edge
const handleRemoveCondition = useCallback(
(edgeId: string) => {
setEdges((eds) =>
eds.map((edge) => {
if (edge.id !== edgeId) return edge
// Remove condition from data
const newData = { ...edge.data }
delete newData.condition
return { ...edge, data: newData }
})
)
setConditionEditor(null)
},
[setEdges]
// Get the selected edge's condition data
const selectedEdge = useMemo(
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null),
[selectedEdgeId, edges]
)
// Close condition editor
const closeConditionEditor = useCallback(() => {
setConditionEditor(null)
}, [])
// Router for navigation
const router = useRouter()
// Handle back button click - show warning if dirty
const handleBackClick = useCallback(() => {
if (isDirty) {
setShowNavigationWarning(true)
} else {
router.push('/dashboard')
}
}, [isDirty, router])
// Confirm navigation (discard unsaved changes)
const handleConfirmNavigation = useCallback(() => {
setShowNavigationWarning(false)
router.push('/dashboard')
}, [router])
// Cancel navigation
const handleCancelNavigation = useCallback(() => {
setShowNavigationWarning(false)
}, [])
return (
<div className="flex h-screen w-full flex-col">
{/* Editor header with back button and project name */}
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
<button
onClick={handleBackClick}
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
aria-label="Back to dashboard"
<EditorProvider value={editorContextValue}>
<div className="flex h-full w-full flex-col">
<Toolbar
onAddDialogue={handleAddDialogue}
onAddChoice={handleAddChoice}
onAddVariable={handleAddVariable}
onSave={handleSave}
onExport={handleExport}
onImport={handleImport}
onProjectSettings={() => setShowSettings(true)}
/>
<div className="flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete}
onEdgeClick={onEdgeClick}
onConnect={onConnect}
deleteKeyCode={['Delete', 'Backspace']}
fitView
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
</button>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{projectName}
</h1>
{isDirty && (
<span className="text-sm text-zinc-500 dark:text-zinc-400">
(unsaved changes)
</span>
)}
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
</div>
</header>
<Toolbar
onAddDialogue={handleAddDialogue}
onAddChoice={handleAddChoice}
onAddVariable={handleAddVariable}
onSave={handleSave}
onExport={handleExport}
onExportRenpy={handleExportRenpy}
onImport={handleImport}
isSaving={isSaving}
/>
<div className="flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onEdgesDelete={onEdgesDelete}
onConnect={onConnect}
onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onEdgeDoubleClick={onEdgeDoubleClick}
deleteKeyCode={['Delete', 'Backspace']}
fitView
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
{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>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
type={contextMenu.type}
onClose={closeContextMenu}
onAddDialogue={() => handleAddNodeAtPosition('dialogue')}
onAddChoice={() => handleAddNodeAtPosition('choice')}
onAddVariable={() => handleAddNodeAtPosition('variable')}
onDelete={
contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge
}
onAddCondition={handleAddCondition}
/>
)}
{conditionEditor && (
<ConditionEditor
edgeId={conditionEditor.edgeId}
condition={conditionEditor.condition}
onSave={handleSaveCondition}
onRemove={handleRemoveCondition}
onCancel={closeConditionEditor}
/>
)}
{/* Draft restoration prompt */}
{draftState.showPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Draft Found
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
A local draft was found that differs from the saved version. Would
you like to restore it or discard it?
</p>
<div className="flex gap-3">
<button
onClick={handleRestoreDraft}
className="flex-1 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
>
Restore Draft
</button>
<button
onClick={handleDiscardDraft}
className="flex-1 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-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Discard
</button>
</div>
</div>
</div>
)}
{/* Import confirmation dialog */}
{importConfirmDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Changes
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
You have unsaved changes. Importing a new file will discard your
current work. Are you sure you want to continue?
</p>
<div className="flex gap-3">
<button
onClick={handleConfirmImport}
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Discard &amp; Import
</button>
<button
onClick={handleCancelImport}
className="flex-1 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-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Navigation warning dialog */}
{showNavigationWarning && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Changes
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
You have unsaved changes that will be lost if you leave this page.
Are you sure you want to leave?
</p>
<div className="flex gap-3">
<button
onClick={handleConfirmNavigation}
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Leave Page
</button>
<button
onClick={handleCancelNavigation}
className="flex-1 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-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Stay
</button>
</div>
</div>
</div>
)}
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".vnflow,.json"
onChange={handleFileSelect}
className="hidden"
/>
{/* Toast notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
action={toast.action}
/>
)}
</div>
</EditorProvider>
)
}

View File

@ -27,9 +27,25 @@ export default async function EditorPage({ params }: PageProps) {
.single()
if (error || !project) {
return (
<div className="flex h-screen flex-col">
<header className="flex items-center border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
notFound()
}
const rawData = project.flowchart_data || {}
const flowchartData: FlowchartData = {
nodes: rawData.nodes || [],
edges: rawData.edges || [],
characters: rawData.characters || [],
variables: rawData.variables || [],
}
// Migration flag: if the raw data doesn't have characters/variables arrays,
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return (
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
@ -78,6 +94,14 @@ export default async function EditorPage({ params }: PageProps) {
</Link>
</div>
</div>
</header>
<div className="flex-1">
<FlowchartEditor
projectId={project.id}
initialData={flowchartData}
needsMigration={needsMigration}
/>
</div>
)
}

View File

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

View File

@ -1,164 +1,320 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useCallback, useMemo, useState } from 'react'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
import type { Condition } from '@/types/flowchart'
type ConditionEditorProps = {
edgeId: string
condition?: Condition
onSave: (edgeId: string, condition: Condition) => void
onRemove: (edgeId: string) => void
onCancel: () => void
condition: Condition | undefined
onChange: (edgeId: string, condition: Condition | undefined) => void
onClose: () => void
}
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
export default function ConditionEditor({
edgeId,
condition,
onSave,
onRemove,
onCancel,
onChange,
onClose,
}: ConditionEditorProps) {
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
const [value, setValue] = useState(condition?.value ?? 0)
const { variables, onAddVariable } = useEditorContext()
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
const variableItems: ComboboxItem[] = useMemo(
() =>
variables.map((v) => ({
id: v.id,
label: v.name,
badge: v.type,
})),
[variables]
)
const selectedVariable = useMemo(() => {
if (!condition?.variableId) return undefined
return variables.find((v) => v.id === condition.variableId)
}, [condition?.variableId, variables])
const hasInvalidReference = useMemo(() => {
if (!condition?.variableId) return false
return !variables.some((v) => v.id === condition.variableId)
}, [condition?.variableId, variables])
// Determine operators based on variable type
const availableOperators = useMemo(() => {
if (!selectedVariable || selectedVariable.type === 'numeric') {
return [
{ value: '==', label: '==' },
{ value: '!=', label: '!=' },
{ value: '>', label: '>' },
{ value: '<', label: '<' },
{ value: '>=', label: '>=' },
{ value: '<=', label: '<=' },
] as const
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
// string and boolean only support == and !=
return [
{ value: '==', label: '==' },
{ value: '!=', label: '!=' },
] as const
}, [selectedVariable])
const handleSave = useCallback(() => {
if (!variableName.trim()) return
onSave(edgeId, {
variableName: variableName.trim(),
operator,
value,
const handleVariableSelect = useCallback(
(variableId: string) => {
const variable = variables.find((v) => v.id === variableId)
const defaultValue = variable
? variable.type === 'numeric'
? 0
: variable.type === 'boolean'
? false
: ''
: 0
// Reset operator if current one is not valid for new type
const validOperator =
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
? '=='
: condition?.operator || '=='
onChange(edgeId, {
variableName: variable?.name || '',
variableId,
operator: validOperator as Condition['operator'],
value: defaultValue,
})
},
[variables, condition?.operator, edgeId, onChange]
)
const handleOperatorChange = useCallback(
(operator: string) => {
if (!condition) return
onChange(edgeId, {
...condition,
operator: operator as Condition['operator'],
})
},
[condition, edgeId, onChange]
)
const handleValueChange = useCallback(
(value: number | string | boolean) => {
if (!condition) return
onChange(edgeId, {
...condition,
value,
})
},
[condition, edgeId, onChange]
)
const handleRemoveCondition = useCallback(() => {
onChange(edgeId, undefined)
onClose()
}, [edgeId, onChange, onClose])
const handleAddNew = useCallback(() => {
setShowAddForm(true)
setNewName('')
setNewType('numeric')
}, [])
const handleSubmitNew = useCallback(() => {
if (!newName.trim()) return
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
const newId = onAddVariable(newName.trim(), newType, defaultValue)
onChange(edgeId, {
variableName: newName.trim(),
variableId: newId,
operator: '==',
value: defaultValue,
})
}, [edgeId, variableName, operator, value, onSave])
setShowAddForm(false)
}, [newName, newType, onAddVariable, edgeId, onChange])
const handleRemove = useCallback(() => {
onRemove(edgeId)
}, [edgeId, onRemove])
const handleCancelNew = useCallback(() => {
setShowAddForm(false)
}, [])
const hasExistingCondition = !!condition
// Render value input based on variable type
const renderValueInput = () => {
const varType = selectedVariable?.type || 'numeric'
if (varType === 'boolean') {
return (
<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 bg-black/50">
<div
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{hasExistingCondition ? 'Edit Condition' : 'Add Condition'}
</h3>
<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>
<div className="space-y-4">
{/* Variable Name Input */}
<div>
<label
htmlFor="variableName"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Variable Name
</label>
<input
type="text"
id="variableName"
value={variableName}
onChange={(e) => setVariableName(e.target.value)}
placeholder="e.g., score, health, affection"
autoFocus
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
{/* Variable selector */}
<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>
{/* Operator Dropdown */}
<div>
<label
htmlFor="operator"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Operator
</label>
<select
id="operator"
value={operator}
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 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-700 dark:text-zinc-100"
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
</option>
))}
</select>
</div>
{/* Value Number Input */}
<div>
<label
htmlFor="value"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Value
</label>
<input
type="number"
id="value"
value={value}
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
/>
</div>
{/* Preview */}
{variableName.trim() && (
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
<span className="text-sm text-zinc-600 dark:text-zinc-300">
Condition: <code className="font-mono text-blue-600 dark:text-blue-400">{variableName.trim()} {operator} {value}</code>
</span>
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found
</div>
)}
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-between">
<div>
{hasExistingCondition && (
<button
type="button"
onClick={handleRemove}
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
{/* 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"
>
Remove
<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>
<div className="flex gap-2">
)}
{/* 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
type="button"
onClick={onCancel}
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"
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"
>
Cancel
Remove condition
</button>
<button
type="button"
onClick={handleSave}
disabled={!variableName.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
) : (
<div />
)}
<button
onClick={onClose}
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
>
Done
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,24 @@
'use client'
import { createContext, useContext } from 'react'
import type { Character, Variable } from '@/types/flowchart'
type EditorContextValue = {
characters: Character[]
onAddCharacter: (name: string, color: string) => string // returns new character id
variables: Variable[]
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
}
const EditorContext = createContext<EditorContextValue>({
characters: [],
onAddCharacter: () => '',
variables: [],
onAddVariable: () => '',
})
export const EditorProvider = EditorContext.Provider
export function useEditorContext() {
return useContext(EditorContext)
}

View File

@ -0,0 +1,386 @@
'use client'
import { useEffect, useState } from 'react'
import { nanoid } from 'nanoid'
import { createClient } from '@/lib/supabase/client'
import type { Character, Variable } from '@/types/flowchart'
type ImportMode = 'characters' | 'variables'
type ProjectListItem = {
id: string
name: string
}
type ImportFromProjectModalProps = {
mode: ImportMode
currentProjectId: string
existingCharacters: Character[]
existingVariables: Variable[]
onImportCharacters: (characters: Character[]) => void
onImportVariables: (variables: Variable[]) => void
onClose: () => void
}
export default function ImportFromProjectModal({
mode,
currentProjectId,
existingCharacters,
existingVariables,
onImportCharacters,
onImportVariables,
onClose,
}: ImportFromProjectModalProps) {
const [projects, setProjects] = useState<ProjectListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [sourceCharacters, setSourceCharacters] = useState<Character[]>([])
const [sourceVariables, setSourceVariables] = useState<Variable[]>([])
const [loadingSource, setLoadingSource] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [warnings, setWarnings] = useState<string[]>([])
// Load user's projects on mount
useEffect(() => {
async function fetchProjects() {
const supabase = createClient()
const { data, error: fetchError } = await supabase
.from('projects')
.select('id, name')
.neq('id', currentProjectId)
.order('name')
if (fetchError) {
setError('Failed to load projects')
setLoading(false)
return
}
setProjects(data || [])
setLoading(false)
}
fetchProjects()
}, [currentProjectId])
// Load source project's characters/variables when a project is selected
const handleSelectProject = async (projectId: string) => {
setSelectedProjectId(projectId)
setLoadingSource(true)
setWarnings([])
setSelectedIds(new Set())
const supabase = createClient()
const { data, error: fetchError } = await supabase
.from('projects')
.select('flowchart_data')
.eq('id', projectId)
.single()
if (fetchError || !data) {
setError('Failed to load project data')
setLoadingSource(false)
return
}
const flowchartData = data.flowchart_data || {}
const chars: Character[] = flowchartData.characters || []
const vars: Variable[] = flowchartData.variables || []
setSourceCharacters(chars)
setSourceVariables(vars)
setLoadingSource(false)
// Select all by default
const items = mode === 'characters' ? chars : vars
setSelectedIds(new Set(items.map((item) => item.id)))
}
const handleToggleItem = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleSelectAll = () => {
const items = mode === 'characters' ? sourceCharacters : sourceVariables
setSelectedIds(new Set(items.map((item) => item.id)))
}
const handleSelectNone = () => {
setSelectedIds(new Set())
}
const handleImport = () => {
const importWarnings: string[] = []
if (mode === 'characters') {
const selectedCharacters = sourceCharacters.filter((c) => selectedIds.has(c.id))
const existingNames = new Set(existingCharacters.map((c) => c.name.toLowerCase()))
const toImport: Character[] = []
for (const char of selectedCharacters) {
if (existingNames.has(char.name.toLowerCase())) {
importWarnings.push(`Skipped "${char.name}" (already exists)`)
} else {
// Create new ID to avoid conflicts
toImport.push({ ...char, id: nanoid() })
existingNames.add(char.name.toLowerCase())
}
}
if (importWarnings.length > 0) {
setWarnings(importWarnings)
}
if (toImport.length > 0) {
onImportCharacters(toImport)
}
if (importWarnings.length === 0) {
onClose()
}
} else {
const selectedVariables = sourceVariables.filter((v) => selectedIds.has(v.id))
const existingNames = new Set(existingVariables.map((v) => v.name.toLowerCase()))
const toImport: Variable[] = []
for (const variable of selectedVariables) {
if (existingNames.has(variable.name.toLowerCase())) {
importWarnings.push(`Skipped "${variable.name}" (already exists)`)
} else {
// Create new ID to avoid conflicts
toImport.push({ ...variable, id: nanoid() })
existingNames.add(variable.name.toLowerCase())
}
}
if (importWarnings.length > 0) {
setWarnings(importWarnings)
}
if (toImport.length > 0) {
onImportVariables(toImport)
}
if (importWarnings.length === 0) {
onClose()
}
}
}
const items = mode === 'characters' ? sourceCharacters : sourceVariables
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
Import {mode === 'characters' ? 'Characters' : 'Variables'} from Project
</h3>
<button
onClick={onClose}
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading && (
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
Loading projects...
</p>
)}
{error && (
<p className="py-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{!loading && !error && !selectedProjectId && (
<>
{projects.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
No other projects found.
</p>
) : (
<div className="space-y-1">
<p className="mb-3 text-sm text-zinc-600 dark:text-zinc-400">
Select a project to import from:
</p>
{projects.map((project) => (
<button
key={project.id}
onClick={() => handleSelectProject(project.id)}
className="flex w-full items-center rounded-lg border border-zinc-200 px-4 py-3 text-left text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-700/50"
>
<svg className="mr-3 h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{project.name}
</button>
))}
</div>
)}
</>
)}
{loadingSource && (
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
Loading {mode}...
</p>
)}
{selectedProjectId && !loadingSource && (
<>
{/* Back button */}
<button
onClick={() => { setSelectedProjectId(null); setWarnings([]) }}
className="mb-3 flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to projects
</button>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
This project has no {mode} defined.
</p>
) : (
<>
{/* Select all/none controls */}
<div className="mb-3 flex items-center gap-3">
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{selectedIds.size} of {items.length} selected
</span>
<button
onClick={handleSelectAll}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Select all
</button>
<button
onClick={handleSelectNone}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Select none
</button>
</div>
{/* Item list with checkboxes */}
<div className="space-y-1">
{mode === 'characters'
? sourceCharacters.map((char) => (
<label
key={char.id}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
>
<input
type="checkbox"
checked={selectedIds.has(char.id)}
onChange={() => handleToggleItem(char.id)}
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
/>
<div
className="h-3.5 w-3.5 rounded-full"
style={{ backgroundColor: char.color }}
/>
<span className="text-sm text-zinc-900 dark:text-zinc-50">
{char.name}
</span>
{char.description && (
<span className="text-xs text-zinc-500 dark:text-zinc-400">
- {char.description}
</span>
)}
</label>
))
: sourceVariables.map((variable) => (
<label
key={variable.id}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
>
<input
type="checkbox"
checked={selectedIds.has(variable.id)}
onChange={() => handleToggleItem(variable.id)}
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
/>
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
variable.type === 'numeric'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: variable.type === 'string'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
}`}>
{variable.type}
</span>
<span className="text-sm text-zinc-900 dark:text-zinc-50">
{variable.name}
</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
(initial: {String(variable.initialValue)})
</span>
</label>
))}
</div>
{/* Warnings */}
{warnings.length > 0 && (
<div className="mt-3 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
<p className="mb-1 text-xs font-medium text-orange-700 dark:text-orange-300">
Import warnings:
</p>
{warnings.map((warning, i) => (
<p key={i} className="text-xs text-orange-600 dark:text-orange-400">
{warning}
</p>
))}
</div>
)}
</>
)}
</>
)}
</div>
{/* Footer with import button */}
{selectedProjectId && !loadingSource && items.length > 0 && (
<div className="flex items-center justify-end gap-3 border-t border-zinc-200 px-6 py-3 dark:border-zinc-700">
<button
onClick={onClose}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={selectedIds.size === 0}
className="rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
Import {selectedIds.size > 0 ? `(${selectedIds.size})` : ''}
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -1,169 +1,313 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useCallback, useMemo, useState } from 'react'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
import type { Condition } from '@/types/flowchart'
type OptionConditionEditorProps = {
optionId: string
optionLabel: string
condition?: Condition
onSave: (optionId: string, condition: Condition) => void
onRemove: (optionId: string) => void
onCancel: () => void
condition: Condition | undefined
onChange: (condition: Condition | undefined) => void
onClose: () => void
}
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
export default function OptionConditionEditor({
optionId,
optionLabel,
condition,
onSave,
onRemove,
onCancel,
onChange,
onClose,
}: OptionConditionEditorProps) {
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
const [value, setValue] = useState(condition?.value ?? 0)
const { variables, onAddVariable } = useEditorContext()
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
const variableItems: ComboboxItem[] = useMemo(
() =>
variables.map((v) => ({
id: v.id,
label: v.name,
badge: v.type,
})),
[variables]
)
const selectedVariable = useMemo(() => {
if (!condition?.variableId) return undefined
return variables.find((v) => v.id === condition.variableId)
}, [condition?.variableId, variables])
const hasInvalidReference = useMemo(() => {
if (!condition?.variableId) return false
return !variables.some((v) => v.id === condition.variableId)
}, [condition?.variableId, variables])
const availableOperators = useMemo(() => {
if (!selectedVariable || selectedVariable.type === 'numeric') {
return [
{ value: '==', label: '==' },
{ value: '!=', label: '!=' },
{ value: '>', label: '>' },
{ value: '<', label: '<' },
{ value: '>=', label: '>=' },
{ value: '<=', label: '<=' },
] as const
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
return [
{ value: '==', label: '==' },
{ value: '!=', label: '!=' },
] as const
}, [selectedVariable])
const handleSave = useCallback(() => {
if (!variableName.trim()) return
onSave(optionId, {
variableName: variableName.trim(),
operator,
value,
const handleVariableSelect = useCallback(
(variableId: string) => {
const variable = variables.find((v) => v.id === variableId)
const defaultValue = variable
? variable.type === 'numeric'
? 0
: variable.type === 'boolean'
? false
: ''
: 0
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,
})
}, [optionId, variableName, operator, value, onSave])
setShowAddForm(false)
}, [newName, newType, onAddVariable, onChange])
const handleRemove = useCallback(() => {
onRemove(optionId)
}, [optionId, onRemove])
const handleCancelNew = useCallback(() => {
setShowAddForm(false)
}, [])
const hasExistingCondition = !!condition
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 bg-black/50">
<div
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-1 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'}
</h3>
<p className="mb-4 text-sm text-zinc-500 dark:text-zinc-400">
Option: {optionLabel || '(unnamed)'}
</p>
<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>
<div className="space-y-4">
{/* Variable Name Input */}
<div>
<label
htmlFor="optionVariableName"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Variable Name
</label>
<input
type="text"
id="optionVariableName"
value={variableName}
onChange={(e) => setVariableName(e.target.value)}
placeholder="e.g., affection, score, health"
autoFocus
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
{/* 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>
{/* Operator Dropdown */}
<div>
<label
htmlFor="optionOperator"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Operator
</label>
<select
id="optionOperator"
value={operator}
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 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-700 dark:text-zinc-100"
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
</option>
))}
</select>
</div>
{/* Value Number Input */}
<div>
<label
htmlFor="optionValue"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Value
</label>
<input
type="number"
id="optionValue"
value={value}
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
/>
</div>
{/* Preview */}
{variableName.trim() && (
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
<span className="text-sm text-zinc-600 dark:text-zinc-300">
Show option when: <code className="font-mono text-amber-600 dark:text-amber-400">{variableName.trim()} {operator} {value}</code>
</span>
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found
</div>
)}
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-between">
<div>
{hasExistingCondition && (
<button
type="button"
onClick={handleRemove}
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
{/* 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"
>
Remove
<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>
<div className="flex gap-2">
)}
{/* 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
type="button"
onClick={onCancel}
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"
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"
>
Cancel
Remove condition
</button>
<button
type="button"
onClick={handleSave}
disabled={!variableName.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
) : (
<div />
)}
<button
onClick={onClose}
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
>
Done
</button>
</div>
</div>
</div>

View File

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

View File

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

View File

@ -1,10 +1,11 @@
'use client'
import { useCallback, useState, ChangeEvent } from 'react'
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
import type { Condition } from '@/types/flowchart'
import { useEditorContext } from '@/components/editor/EditorContext'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
import type { Condition } from '@/types/flowchart'
type ChoiceOption = {
id: string
@ -58,6 +59,27 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[id, setNodes]
)
const updateOptionCondition = useCallback(
(optionId: string, condition: Condition | undefined) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, condition } : opt
),
},
}
: node
)
)
},
[id, setNodes]
)
const addOption = useCallback(() => {
if (data.options.length >= MAX_OPTIONS) return
setNodes((nodes) =>
@ -100,68 +122,109 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[id, data.options.length, setNodes]
)
const handleSaveCondition = useCallback(
(optionId: string, condition: Condition) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, condition } : opt
),
},
}
: node
)
)
setEditingConditionOptionId(null)
},
[id, setNodes]
)
const editingOption = useMemo(() => {
if (!editingConditionOptionId) return null
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
}, [editingConditionOptionId, data.options])
const handleRemoveCondition = useCallback(
(optionId: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) => {
if (opt.id !== optionId) return opt
const updated = { ...opt }
delete updated.condition
return updated
}),
},
}
: node
)
)
setEditingConditionOptionId(null)
const hasInvalidConditionReference = useCallback(
(option: ChoiceOption) => {
if (!option.condition?.variableId) return false
return !variables.some((v) => v.id === option.condition!.variableId)
},
[id, setNodes]
[variables]
)
const editingOption = editingConditionOptionId
? data.options.find((opt) => opt.id === editingConditionOptionId)
: null
return (
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
<Handle
type="target"
position={Position.Top}
id="input"
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
/>
<>
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
<Handle
type="target"
position={Position.Top}
id="input"
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
/>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
Choice
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
Choice
</div>
<input
type="text"
value={data.prompt || ''}
onChange={updatePrompt}
placeholder="What do you choose?"
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<div className="space-y-2">
{data.options.map((option, index) => (
<div key={option.id}>
<div className="relative flex items-center gap-1">
<input
type="text"
value={option.label}
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
placeholder={`Option ${index + 1}`}
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<button
type="button"
onClick={() => setEditingConditionOptionId(option.id)}
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
option.condition?.variableId
? hasInvalidConditionReference(option)
? 'bg-orange-100 text-orange-600 ring-1 ring-orange-500 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700'
}`}
title={option.condition?.variableId ? 'Edit condition' : 'Add condition'}
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</button>
<button
type="button"
onClick={() => removeOption(option.id)}
disabled={data.options.length <= MIN_OPTIONS}
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
title="Remove option"
>
×
</button>
<Handle
type="source"
position={Position.Bottom}
id={`option-${index}`}
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
style={{
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
}}
/>
</div>
{option.condition?.variableId && (
<div className={`mt-0.5 ml-1 text-[10px] ${
hasInvalidConditionReference(option)
? 'text-orange-500 dark:text-orange-400'
: 'text-zinc-500 dark:text-zinc-400'
}`}>
if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)}
</div>
)}
</div>
))}
</div>
<button
type="button"
onClick={addOption}
disabled={data.options.length >= MAX_OPTIONS}
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
title="Add option"
>
+ Add Option
</button>
</div>
<input

View File

@ -1,22 +1,56 @@
'use client'
import { useCallback, ChangeEvent } from 'react'
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
type DialogueNodeData = {
speaker?: string
characterId?: string
text: string
}
const RANDOM_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
function randomColor(): string {
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
}
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
const { setNodes } = useReactFlow()
const { characters, onAddCharacter } = useEditorContext()
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newColor, setNewColor] = useState(randomColor)
const characterItems: ComboboxItem[] = useMemo(
() =>
characters.map((c) => ({
id: c.id,
label: c.name,
color: c.color,
})),
[characters]
)
const hasInvalidReference = useMemo(() => {
if (!data.characterId) return false
return !characters.some((c) => c.id === data.characterId)
}, [data.characterId, characters])
const updateNodeData = useCallback(
(field: keyof DialogueNodeData, value: string) => {
(updates: Partial<DialogueNodeData>) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, [field]: value } }
? { ...node, data: { ...node.data, ...updates } }
: node
)
)
@ -24,22 +58,52 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
[id, setNodes]
)
const handleSpeakerChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
updateNodeData('speaker', e.target.value)
const handleCharacterSelect = useCallback(
(characterId: string) => {
const character = characters.find((c) => c.id === characterId)
updateNodeData({
characterId,
speaker: character?.name || '',
})
},
[updateNodeData]
[characters, updateNodeData]
)
const handleAddNew = useCallback(() => {
setShowAddForm(true)
setNewName('')
setNewColor(randomColor())
}, [])
const handleSubmitNew = useCallback(() => {
if (!newName.trim()) return
const newId = onAddCharacter(newName.trim(), newColor)
updateNodeData({
characterId: newId,
speaker: newName.trim(),
})
setShowAddForm(false)
}, [newName, newColor, onAddCharacter, updateNodeData])
const handleCancelNew = useCallback(() => {
setShowAddForm(false)
}, [])
const handleTextChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
updateNodeData('text', e.target.value)
updateNodeData({ text: e.target.value })
},
[updateNodeData]
)
return (
<div className="min-w-[200px] rounded-lg border-2 border-blue-500 bg-blue-50 p-3 shadow-md dark:border-blue-400 dark:bg-blue-950">
<div
className={`min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-blue-500 dark:border-blue-400'
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
>
<Handle
type="target"
position={Position.Top}
@ -51,13 +115,63 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
Dialogue
</div>
<input
type="text"
value={data.speaker || ''}
onChange={handleSpeakerChange}
placeholder="Speaker"
className="mb-2 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<div className="mb-2">
<Combobox
items={characterItems}
value={data.characterId}
onChange={handleCharacterSelect}
placeholder="Select speaker..."
onAddNew={handleAddNew}
/>
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Character not found
</div>
)}
</div>
{showAddForm && (
<div className="mb-2 rounded border border-blue-300 bg-white p-2 dark:border-blue-600 dark:bg-zinc-800">
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
New character
</div>
<div className="flex items-center gap-1.5">
<input
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
/>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Name"
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmitNew()
if (e.key === 'Escape') handleCancelNew()
}}
autoFocus
/>
</div>
<div className="mt-1.5 flex justify-end gap-1">
<button
onClick={handleCancelNew}
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Cancel
</button>
<button
onClick={handleSubmitNew}
disabled={!newName.trim()}
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
)}
<textarea
value={data.text || ''}

View File

@ -1,23 +1,52 @@
'use client'
import { useCallback, ChangeEvent } from 'react'
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
type VariableNodeData = {
variableName: string
variableId?: string
operation: 'set' | 'add' | 'subtract'
value: number
}
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
const { setNodes } = useReactFlow()
const { variables, onAddVariable } = useEditorContext()
const updateVariableName = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
const variableItems: ComboboxItem[] = useMemo(
() =>
variables.map((v) => ({
id: v.id,
label: v.name,
badge: v.type,
})),
[variables]
)
const selectedVariable = useMemo(() => {
if (!data.variableId) return undefined
return variables.find((v) => v.id === data.variableId)
}, [data.variableId, variables])
const hasInvalidReference = useMemo(() => {
if (!data.variableId) return false
return !variables.some((v) => v.id === data.variableId)
}, [data.variableId, variables])
const updateNodeData = useCallback(
(updates: Partial<VariableNodeData>) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, variableName: e.target.value } }
? { ...node, data: { ...node.data, ...updates } }
: node
)
)
@ -25,35 +54,69 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
[id, setNodes]
)
const handleVariableSelect = useCallback(
(variableId: string) => {
const variable = variables.find((v) => v.id === variableId)
const updates: Partial<VariableNodeData> = {
variableId,
variableName: variable?.name || '',
}
// Reset operation to 'set' if current operation is not valid for the variable's type
if (variable && variable.type !== 'numeric' && (data.operation === 'add' || data.operation === 'subtract')) {
updates.operation = 'set'
}
updateNodeData(updates)
},
[variables, data.operation, updateNodeData]
)
const handleAddNew = useCallback(() => {
setShowAddForm(true)
setNewName('')
setNewType('numeric')
}, [])
const handleSubmitNew = useCallback(() => {
if (!newName.trim()) return
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
const newId = onAddVariable(newName.trim(), newType, defaultValue)
updateNodeData({
variableId: newId,
variableName: newName.trim(),
})
setShowAddForm(false)
}, [newName, newType, onAddVariable, updateNodeData])
const handleCancelNew = useCallback(() => {
setShowAddForm(false)
}, [])
const updateOperation = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
: node
)
)
updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' })
},
[id, setNodes]
[updateNodeData]
)
const updateValue = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const value = parseFloat(e.target.value) || 0
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, value } }
: node
)
)
updateNodeData({ value })
},
[id, setNodes]
[updateNodeData]
)
// Filter operations based on selected variable type
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
return (
<div className="min-w-[200px] rounded-lg border-2 border-orange-500 bg-orange-50 p-3 shadow-md dark:border-orange-400 dark:bg-orange-950">
<div
className={`min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-orange-500 dark:border-orange-400'
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
>
<Handle
type="target"
position={Position.Top}
@ -65,13 +128,68 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
Variable
</div>
<input
type="text"
value={data.variableName || ''}
onChange={updateVariableName}
placeholder="variableName"
className="mb-2 w-full rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<div className={`mb-2 ${hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}`}>
<Combobox
items={variableItems}
value={data.variableId}
onChange={handleVariableSelect}
placeholder="Select variable..."
onAddNew={handleAddNew}
/>
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found
</div>
)}
</div>
{showAddForm && (
<div className="mb-2 rounded border border-orange-300 bg-white p-2 dark:border-orange-600 dark:bg-zinc-800">
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
New variable
</div>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Name"
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmitNew()
if (e.key === 'Escape') handleCancelNew()
}}
autoFocus
/>
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<select
value={newType}
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
>
<option value="numeric">numeric</option>
<option value="string">string</option>
<option value="boolean">boolean</option>
</select>
</div>
<div className="mt-1.5 flex justify-end gap-1">
<button
onClick={handleCancelNew}
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Cancel
</button>
<button
onClick={handleSubmitNew}
disabled={!newName.trim()}
className="rounded bg-orange-600 px-2 py-0.5 text-xs text-white hover:bg-orange-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
)}
<div className="mb-2 flex gap-2">
<select
@ -80,8 +198,8 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
>
<option value="set">set</option>
<option value="add">add</option>
<option value="subtract">subtract</option>
{isNumeric && <option value="add">add</option>}
{isNumeric && <option value="subtract">subtract</option>}
</select>
<input

View File

@ -4,22 +4,51 @@ export type Position = {
y: number;
};
<<<<<<< HEAD
// Condition type for conditional edges and choice options
export type Condition = {
variableName: string;
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
value: number;
=======
// Character type: represents a defined character in the project
export type Character = {
id: string;
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;
>>>>>>> ralph/collaboration-and-character-variables
};
// DialogueNode type: represents character speech/dialogue
export type DialogueNodeData = {
speaker?: string;
characterId?: string;
text: string;
};
export type DialogueNode = {
id: string;
type: 'dialogue';
position: Position;
data: {
speaker?: string;
text: string;
};
data: DialogueNodeData;
};
// Choice option type for ChoiceNode
@ -41,15 +70,18 @@ export type ChoiceNode = {
};
// VariableNode type: represents variable operations
export type VariableNodeData = {
variableName: string;
variableId?: string;
operation: 'set' | 'add' | 'subtract';
value: number;
};
export type VariableNode = {
id: string;
type: 'variable';
position: Position;
data: {
variableName: string;
operation: 'set' | 'add' | 'subtract';
value: number;
};
data: VariableNodeData;
};
// Union type for all node types
@ -71,4 +103,6 @@ export type FlowchartEdge = {
export type FlowchartData = {
nodes: FlowchartNode[];
edges: FlowchartEdge[];
characters: Character[];
variables: Variable[];
};

View File

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