47 Commits
Author SHA1 Message Date
GHMiranda f077d902b4 Merge pull request 'developing' (#10) from developing into master
Reviewed-on: #10
2026-01-25 00:37:10 +00:00
GHMiranda 75750cf226 Merge pull request 'ralph/collaboration-and-character-variables' (#9) from ralph/collaboration-and-character-variables into developing
Reviewed-on: #9
2026-01-25 00:11:20 +00:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 cd3ecc4507 fix: only force-reconnect on tab visibility change, not on activity gaps
The 60s stale threshold was triggering during normal use (reading, thinking)
causing constant reconnects and presence join/leave toast spam. Now force-
reconnect only happens when the tab was hidden for 3+ minutes and becomes
visible again. Regular activity (mouse/keyboard) only resets the inactivity
timer without ever forcing a reconnect.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 19:35:00 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 3b19f58e26 fix: wait for auth session before connecting to realtime channel
On initial page load, the Supabase browser client's auth session may not
be ready yet (still loading from cookies). The Realtime channel subscription
silently fails without a valid access token. Now connect() awaits getSession()
first, which ensures the token is available and also refreshes expired tokens
on reconnect after inactivity.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 19:22:22 -03:00
Gustavo Henrique Santos Souza de Miranda 9769a80381 Merge branch 'developing' of git.gustavomiranda.xyz:GHMiranda/WebVNWrite into ralph/collaboration-and-character-variables 2026-01-24 19:15:17 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 c28b9ac565 fix: force fresh reconnect when returning after idle period
The WebSocket can go stale during inactivity without triggering any error
status (channel still shows 'joined' but socket is dead). Now when the user
returns after 60+ seconds of inactivity, the connection is torn down and
re-established to guarantee a fresh WebSocket. This ensures CRDT broadcasts
actually reach other clients after returning from idle.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 19:14:14 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 fa8437d03a fix: broadcast full CRDT state on connect and when new user joins
CRDT broadcasts are fire-and-forget, so any updates missed during a
disconnection were permanently lost. Now when a client connects (or
reconnects), it broadcasts its full Yjs doc state. When an existing client
sees a new user join, it also broadcasts its full state. Yjs merges
handle deduplication automatically, so this converges all clients.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 19:06:20 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 2d0c3b6df6 fix: reduce auto-save frequency to avoid excessive writes
Increase CRDT DB persist debounce from 2s to 30s since it's only needed for
crash recovery (CRDT handles real-time sync). Increase LocalStorage draft
save debounce from 1s to 5s.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:58:54 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 ec053d8126 fix: stop state ping-pong by removing broadcastStateRefresh from auto-persist
The CRDT auto-persist was broadcasting state-refresh after each save, causing
other clients to fetch from DB and overwrite their local variables/characters
with stale values. Since CRDT already syncs nodes/edges via yjs-update
broadcasts, the state-refresh from auto-persist was redundant and destructive.
Manual save still broadcasts state-refresh as intended.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:46:59 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 cd73b31739 fix: improve realtime connection resilience with auto-reconnect and activity-based lifecycle
Add connection timeout (15s) to handle stale initial subscribes, inactivity
pause (5min) to save resources when idle, and automatic resume on user activity
or tab focus. The heartbeat now detects unhealthy channel states and consecutive
failures to trigger reconnects. Unexpected CLOSED status also triggers reconnect
instead of staying disconnected silently.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:20:41 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 eb86ccd291 feat: notify collaborators to refresh from DB after save
Instead of relying on Yjs broadcast serialization (which has delivery
issues), use a lightweight state-refresh broadcast event. When any
client persists (manual save or CRDT auto-persist), it broadcasts
state-refresh. Other clients fetch the latest flowchart_data from the
database and update their local state and CRDT.

Added isSuppressed flag to CRDTManager to prevent broadcast/persist
loops during initialization and refresh operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:36:43 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 cdaae6b965 feat: broadcast full CRDT state to other clients after manual save
When a user saves, the full Yjs document state is broadcast so all
connected clients converge, even if they missed incremental updates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:12:58 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 34815d70ee fix: resolve CRDT collaboration sync by registering broadcast listener before channel subscribe
The yjs-update broadcast listener was added after the Supabase channel
was already subscribed, which meant it never received messages. Moved
the listener registration to the builder chain before .subscribe()
(matching how cursor/node-lock listeners work), and removed the broken
isRemoteUpdateRef guard that caused ReferenceErrors preventing local
changes from reaching the CRDT.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:08:32 -03:00
Gustavo Henrique Santos Souza de Miranda dc7875eeea fix merge 2026-01-24 03:13:53 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 c9f5231137 docs: update PRD and progress for US-053
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:58:14 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 315e34e25e feat: [US-053] - Revert changes from audit trail
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:57:36 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 f06a30b2bf feat: [US-052] - Activity history sidebar
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:53:31 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 6c4a3ba2b7 docs: update PRD and progress for US-051
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:47:41 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 6ef5cfc7fa feat: [US-051] - Audit trail recording
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:46:52 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 4a85d7a64b feat: [US-049] - Node editing lock indicators
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:43:06 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 5b84170a28 fix: allow profile lookup for sharing by adding RLS policy
The profiles table RLS policies only allowed users to view their own
profile, causing the share feature to fail when searching for users
by email. Added a policy allowing any authenticated user to read profiles.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 02:38:05 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 ccb05e3a3e feat: [US-050] - Join/leave notifications
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:08:05 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 841a44112a feat: [US-047] - Live cursor positions on canvas
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:04:09 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 7fe10544a1 feat: [US-047] - Live cursor positions on canvas
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:03:17 -03:00
GHMiranda d2c48c0c9a Merge pull request 'ralph/collaboration-and-character-variables' (#8) from ralph/collaboration-and-character-variables into developing
Reviewed-on: #8
2026-01-24 00:00:47 +00:00
GHMiranda bda19a2970 Merge branch 'developing' into ralph/collaboration-and-character-variables 2026-01-23 23:59:55 +00:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 b6d85b1a47 fix: add isSaving prop and back-to-dashboard navigation to Toolbar
Add isSaving as a prop so the save button properly shows disabled/spinner
state. Add a back arrow link to /dashboard for editor navigation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:55:07 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 8f6cff52a9 fix: correct OptionConditionEditor props in ChoiceNode
ChoiceNode was passing incompatible props (optionId, optionLabel, onSave,
onRemove, onCancel) to OptionConditionEditor which expects (condition,
onChange, onClose). This caused a build failure preventing the component
tree from rendering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:54:54 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 6bc4e32fde fix: remove CRDT observer feedback loop causing drag issues
The Yjs map observers were calling notifyNodesChange/notifyEdgesChange
for local changes, creating a feedback loop that reset React Flow state
mid-drag. Remote notifications are already handled by the connectChannel
handler, so the observers only need to schedule persistence.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:54:45 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 fa336f05d9 fix: restore missing utilities, context menu, and props from failed merge
The merge at bcbad57 lost utility functions, type definitions, and
handlers from the developing branch. This restores: imports (useRef,
useEffect, ConditionalEdge, ContextMenu), constants (AUTOSAVE_DEBOUNCE_MS),
types (ContextMenuState, ConditionEditorState), utility functions
(fromReactFlowNodes, fromReactFlowEdges, saveDraft, loadDraft, clearDraft,
flowchartDataEquals, isValidFlowchartData, convertToRenpyFormat), context
menu handlers, proper FlowchartEditorProps (userId, userDisplayName,
isOwner), ReactFlow container height (h-screen), and fixes stale closure
dependency arrays for characters/variables.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:54:29 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 841eefb186 fix: resolve infinite recursion in RLS policies
Use SECURITY DEFINER helper functions to break circular dependencies
between projects and project_collaborators table policies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:11:58 -03:00
GHMiranda 76761e878b Merge pull request 'ralph/collaboration-and-character-variables' (#7) from ralph/collaboration-and-character-variables into developing
Reviewed-on: #7
2026-01-23 18:59:45 +00:00
Gustavo Henrique Santos Souza de Miranda bcbad57aa9 Merge branch 'developing' into ralph/collaboration-and-character-variables 2026-01-23 15:59:17 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 409b1848c8 chore: mark US-048 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:47:29 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 6b7b27236f feat: [US-048] - Integrate Yjs CRDT for conflict-free node/edge synchronization
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:46:39 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 d75e2daeb0 chore: mark US-046 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:43:06 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 f92fc1ad01 feat: [US-046] - Presence indicators for active collaborators
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:42:07 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 7278b7527f chore: mark US-044 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:38:10 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 37031c3889 feat: [US-044] - Project sharing and collaborator management
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:36:58 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 46b681462d chore: mark US-045 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:29:16 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 2e313a0264 feat: [US-045] - Supabase Realtime channel and connection management
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:28:23 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 dfbaa0066d chore: mark US-043 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:23:55 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 2b4abd1eb7 feat: [US-043] - Database schema for collaboration sessions and audit trail
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:23:03 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 273130316b chore: mark US-064 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:21:43 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 0d72471f8f feat: [US-064] - Export validation for undefined references
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:21:09 -03:00
Gustavo Henrique Santos Souza de Miranda 8cdba7f858 fix: wrongfull jsx without single parent 2026-01-23 15:17:08 -03:00
Gustavo Henrique Santos Souza de Miranda 190e25228b fix: accepted git conflicts 2026-01-23 12:42:51 -03:00
36 changed files with 4015 additions and 3596 deletions
@@ -1,752 +0,0 @@
{
"project": "WebVNWrite",
"branchName": "ralph/vn-flowchart-editor",
"description": "Visual Novel Flowchart Editor - A web-based tool for authoring visual novels with drag-and-drop nodes, branching connections, user authentication, and Ren'Py JSON export",
"userStories": [
{
"id": "US-001",
"title": "Project scaffolding and configuration",
"description": "As a developer, I need the project set up with Next.js, TailwindCSS, and Supabase so that I can build the application.",
"acceptanceCriteria": [
"Initialize Next.js project with TypeScript and App Router",
"Install and configure TailwindCSS",
"Install Supabase client library (@supabase/supabase-js)",
"Create .env.example with NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY",
"Basic folder structure: app/, components/, lib/, types/",
"Typecheck passes"
],
"priority": 1,
"passes": true,
"notes": ""
},
{
"id": "US-002",
"title": "Define TypeScript types for flowchart data",
"description": "As a developer, I need TypeScript types for nodes, connections, and conditions.",
"acceptanceCriteria": [
"Create types/flowchart.ts file",
"DialogueNode type: id, type='dialogue', position: {x,y}, data: { speaker?: string, text: string }",
"ChoiceNode type: id, type='choice', position: {x,y}, data: { prompt: string, options: { id: string, label: string }[] }",
"VariableNode type: id, type='variable', position: {x,y}, data: { variableName: string, operation: 'set'|'add'|'subtract', value: number }",
"Condition type: { variableName: string, operator: '>'|'<'|'=='|'>='|'<='|'!=', value: number }",
"FlowchartEdge type: id, source, sourceHandle?, target, targetHandle?, data?: { condition?: Condition }",
"FlowchartData type: { nodes: (DialogueNode|ChoiceNode|VariableNode)[], edges: FlowchartEdge[] }",
"All types exported from types/flowchart.ts",
"Typecheck passes"
],
"priority": 2,
"passes": true,
"notes": ""
},
{
"id": "US-003",
"title": "Supabase schema for users and projects",
"description": "As a developer, I need database tables to store users and their projects.",
"acceptanceCriteria": [
"Create supabase/migrations/ directory",
"Create SQL migration file with profiles table: id (uuid, references auth.users), email (text), display_name (text), is_admin (boolean default false), created_at (timestamptz)",
"Create projects table: id (uuid), user_id (uuid, foreign key to profiles.id), name (text), flowchart_data (jsonb), created_at (timestamptz), updated_at (timestamptz)",
"Add RLS policy: users can SELECT/INSERT/UPDATE/DELETE their own projects (user_id = auth.uid())",
"Add RLS policy: users can SELECT their own profile",
"Add RLS policy: admin users (is_admin=true) can SELECT all profiles",
"Typecheck passes"
],
"priority": 3,
"passes": true,
"notes": ""
},
{
"id": "US-004",
"title": "Supabase client configuration",
"description": "As a developer, I need Supabase client utilities for auth and database access.",
"acceptanceCriteria": [
"Create lib/supabase/client.ts with browser client (createBrowserClient)",
"Create lib/supabase/server.ts with server client (createServerClient for App Router)",
"Create lib/supabase/middleware.ts with middleware client helper",
"Export typed database client using generated types or manual types",
"Typecheck passes"
],
"priority": 4,
"passes": true,
"notes": ""
},
{
"id": "US-005",
"title": "Protected routes middleware",
"description": "As a developer, I need authentication middleware so that only logged-in users can access the app.",
"acceptanceCriteria": [
"Create middleware.ts at project root",
"Middleware checks Supabase session on each request",
"Unauthenticated users accessing /dashboard or /editor/* are redirected to /login",
"Authenticated users accessing /login or /signup are redirected to /dashboard",
"Public routes allowed without auth: /login, /signup, /forgot-password, /reset-password",
"Typecheck passes"
],
"priority": 5,
"passes": true,
"notes": ""
},
{
"id": "US-006",
"title": "Login page",
"description": "As a user, I want to log in with my email and password so that I can access my projects.",
"acceptanceCriteria": [
"Create app/login/page.tsx",
"Form with email and password input fields",
"Submit button calls Supabase signInWithPassword",
"Show error message for invalid credentials",
"On success, redirect to /dashboard",
"Link to /forgot-password page",
"Styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 6,
"passes": true,
"notes": ""
},
{
"id": "US-007",
"title": "Sign up page (invite-only)",
"description": "As an invited user, I want to complete my account setup so that I can access the tool.",
"acceptanceCriteria": [
"Create app/signup/page.tsx",
"Form with email (pre-filled if from invite link), password, and confirm password fields",
"Validate passwords match before submission",
"Handle Supabase invite token from URL (type=invite or type=signup)",
"On success, create profile record in profiles table and redirect to /dashboard",
"Show error message if signup fails",
"Styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 7,
"passes": true,
"notes": ""
},
{
"id": "US-008",
"title": "Logout functionality",
"description": "As a user, I want to log out so that I can secure my session.",
"acceptanceCriteria": [
"Create components/LogoutButton.tsx component",
"Button calls Supabase signOut",
"On success, redirect to /login",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 8,
"passes": true,
"notes": ""
},
{
"id": "US-009",
"title": "Password reset - forgot password page",
"description": "As a user, I want to request a password reset if I forget my password.",
"acceptanceCriteria": [
"Create app/forgot-password/page.tsx",
"Form with email input field",
"Submit button calls Supabase resetPasswordForEmail",
"Show confirmation message after sending (check your email)",
"Link back to /login",
"Styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 9,
"passes": true,
"notes": ""
},
{
"id": "US-010",
"title": "Password reset - set new password page",
"description": "As a user, I want to set a new password after clicking the reset link.",
"acceptanceCriteria": [
"Create app/reset-password/page.tsx",
"Form with new password and confirm password fields",
"Handle Supabase recovery token from URL",
"Submit calls Supabase updateUser with new password",
"On success, redirect to /login with success message",
"Show error if token invalid or expired",
"Styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 10,
"passes": true,
"notes": ""
},
{
"id": "US-011",
"title": "Dashboard layout with navbar",
"description": "As a user, I want a consistent layout with navigation so that I can move around the app.",
"acceptanceCriteria": [
"Create app/dashboard/layout.tsx",
"Navbar component with app title/logo",
"Navbar shows current user email",
"Navbar includes LogoutButton",
"Main content area below navbar",
"Styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 11,
"passes": true,
"notes": ""
},
{
"id": "US-012",
"title": "Dashboard - list projects",
"description": "As a user, I want to see all my projects so that I can choose which one to edit.",
"acceptanceCriteria": [
"Create app/dashboard/page.tsx",
"Fetch projects from Supabase for current user",
"Display projects as cards in a grid",
"Each card shows: project name, last updated date (formatted)",
"Click card navigates to /editor/[projectId]",
"Empty state with message when no projects exist",
"Loading state while fetching",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 12,
"passes": true,
"notes": ""
},
{
"id": "US-013",
"title": "Create new project",
"description": "As a user, I want to create a new project so that I can start a new flowchart.",
"acceptanceCriteria": [
"Add 'New Project' button on dashboard",
"Clicking opens modal with project name input",
"Submit creates project in Supabase with empty flowchart_data: { nodes: [], edges: [] }",
"On success, redirect to /editor/[newProjectId]",
"Show error if creation fails",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 13,
"passes": true,
"notes": ""
},
{
"id": "US-014",
"title": "Delete project",
"description": "As a user, I want to delete a project I no longer need.",
"acceptanceCriteria": [
"Add delete icon/button on each project card",
"Clicking shows confirmation dialog (Are you sure?)",
"Confirm deletes project from Supabase",
"Project removed from dashboard list without page reload",
"Show success toast after deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 14,
"passes": true,
"notes": ""
},
{
"id": "US-015",
"title": "Rename project",
"description": "As a user, I want to rename a project to keep my work organized.",
"acceptanceCriteria": [
"Add edit/rename icon on project card",
"Clicking opens modal or enables inline edit for project name",
"Submit updates project name in Supabase",
"UI updates immediately without page reload",
"Show error if rename fails",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 15,
"passes": true,
"notes": ""
},
{
"id": "US-016",
"title": "Admin - invite new user",
"description": "As an admin, I want to invite new users so that collaborators can access the tool.",
"acceptanceCriteria": [
"Create app/admin/invite/page.tsx",
"Only accessible by users with is_admin=true (redirect others to /dashboard)",
"Form with email address input",
"Submit calls Supabase admin inviteUserByEmail (requires service role key in server action)",
"Show success message with invite sent confirmation",
"Show error if invite fails",
"Link to this page visible in navbar only for admins",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 16,
"passes": true,
"notes": ""
},
{
"id": "US-017",
"title": "Editor page with React Flow canvas",
"description": "As a user, I want an editor page with a canvas where I can build my flowchart.",
"acceptanceCriteria": [
"Install reactflow package",
"Create app/editor/[projectId]/page.tsx",
"Fetch project from Supabase by ID",
"Show error if project not found or user unauthorized",
"Show loading state while fetching",
"Render React Flow canvas filling the editor area",
"Canvas has grid background (React Flow Background component)",
"Header shows project name with back link to /dashboard",
"Initialize React Flow with nodes and edges from flowchart_data",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 17,
"passes": true,
"notes": ""
},
{
"id": "US-018",
"title": "Canvas pan and zoom controls",
"description": "As a user, I want to pan and zoom the canvas to navigate large flowcharts.",
"acceptanceCriteria": [
"Canvas supports click-and-drag panning (React Flow default)",
"Mouse wheel zooms in/out (React Flow default)",
"Add React Flow Controls component with zoom +/- buttons",
"Add fitView button to show all nodes",
"Controls positioned in bottom-right corner",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 18,
"passes": true,
"notes": ""
},
{
"id": "US-019",
"title": "Editor toolbar",
"description": "As a user, I want a toolbar with actions for adding nodes and saving/exporting.",
"acceptanceCriteria": [
"Create components/editor/Toolbar.tsx",
"Toolbar positioned at top of editor below header",
"Buttons: Add Dialogue, Add Choice, Add Variable (no functionality yet)",
"Buttons: Save, Export, Import (no functionality yet)",
"Buttons styled with TailwindCSS, icons optional",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 19,
"passes": true,
"notes": ""
},
{
"id": "US-020",
"title": "Create custom dialogue node component",
"description": "As a user, I want dialogue nodes to display and edit character speech.",
"acceptanceCriteria": [
"Create components/editor/nodes/DialogueNode.tsx",
"Node styled with blue background/border",
"Displays editable input for speaker name (placeholder: 'Speaker')",
"Displays editable textarea for dialogue text (placeholder: 'Dialogue text...')",
"Has one Handle at top (type='target', id='input')",
"Has one Handle at bottom (type='source', id='output')",
"Register as custom node type in React Flow",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 20,
"passes": true,
"notes": ""
},
{
"id": "US-021",
"title": "Add dialogue node from toolbar",
"description": "As a user, I want to add dialogue nodes by clicking the toolbar button.",
"acceptanceCriteria": [
"Clicking 'Add Dialogue' in toolbar creates new DialogueNode",
"Node appears at center of current viewport",
"Node has unique ID (use nanoid or uuid)",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 21,
"passes": true,
"notes": ""
},
{
"id": "US-022",
"title": "Create custom choice node component",
"description": "As a user, I want choice nodes to display branching decisions.",
"acceptanceCriteria": [
"Create components/editor/nodes/ChoiceNode.tsx",
"Node styled with green background/border",
"Displays editable input for prompt text (placeholder: 'What do you choose?')",
"Displays 2 default options, each with editable label input",
"Has one Handle at top (type='target', id='input')",
"Each option has its own Handle at bottom (type='source', id='option-0', 'option-1', etc.)",
"Register as custom node type in React Flow",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 22,
"passes": true,
"notes": ""
},
{
"id": "US-023",
"title": "Add choice node from toolbar",
"description": "As a user, I want to add choice nodes by clicking the toolbar button.",
"acceptanceCriteria": [
"Clicking 'Add Choice' in toolbar creates new ChoiceNode",
"Node appears at center of current viewport",
"Node has unique ID",
"Node initialized with 2 options (each with unique id and empty label)",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 23,
"passes": true,
"notes": ""
},
{
"id": "US-024",
"title": "Add/remove choice options",
"description": "As a user, I want to add or remove choice options (2-6 options supported).",
"acceptanceCriteria": [
"ChoiceNode has '+' button to add new option",
"Maximum 6 options (button disabled or hidden at max)",
"Each option has 'x' button to remove it",
"Minimum 2 options (remove button disabled or hidden at min)",
"Adding option creates new output Handle dynamically",
"Removing option removes its Handle",
"Node data updates in React Flow state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 24,
"passes": true,
"notes": ""
},
{
"id": "US-025",
"title": "Create custom variable node component",
"description": "As a user, I want variable nodes to set or modify story variables.",
"acceptanceCriteria": [
"Create components/editor/nodes/VariableNode.tsx",
"Node styled with orange background/border",
"Displays editable input for variable name (placeholder: 'variableName')",
"Displays dropdown/select for operation: set, add, subtract",
"Displays editable number input for value (default: 0)",
"Has one Handle at top (type='target', id='input')",
"Has one Handle at bottom (type='source', id='output')",
"Register as custom node type in React Flow",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 25,
"passes": true,
"notes": ""
},
{
"id": "US-026",
"title": "Add variable node from toolbar",
"description": "As a user, I want to add variable nodes by clicking the toolbar button.",
"acceptanceCriteria": [
"Clicking 'Add Variable' in toolbar creates new VariableNode",
"Node appears at center of current viewport",
"Node has unique ID",
"Node initialized with empty variableName, operation='set', value=0",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 26,
"passes": true,
"notes": ""
},
{
"id": "US-027",
"title": "Connect nodes with edges",
"description": "As a user, I want to connect nodes with arrows to define story flow.",
"acceptanceCriteria": [
"Dragging from source Handle to target Handle creates edge (React Flow default)",
"Edges render as smooth bezier curves (default edge type or smoothstep)",
"Edges show arrow marker indicating direction (markerEnd)",
"Edges update position when nodes are moved",
"Cannot connect source-to-source or target-to-target (React Flow handles this)",
"New edges added to React Flow edges state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 27,
"passes": true,
"notes": ""
},
{
"id": "US-028",
"title": "Select and delete nodes",
"description": "As a user, I want to delete nodes to revise my flowchart.",
"acceptanceCriteria": [
"Clicking a node selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected node(s)",
"Deleting node also removes all connected edges",
"Use onNodesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 28,
"passes": true,
"notes": ""
},
{
"id": "US-029",
"title": "Select and delete edges",
"description": "As a user, I want to delete connections between nodes.",
"acceptanceCriteria": [
"Clicking an edge selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected edge(s)",
"Use onEdgesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 29,
"passes": true,
"notes": ""
},
{
"id": "US-030",
"title": "Right-click context menu",
"description": "As a user, I want a context menu for quick actions.",
"acceptanceCriteria": [
"Create components/editor/ContextMenu.tsx",
"Right-click on canvas shows menu: Add Dialogue, Add Choice, Add Variable",
"New node created at click position",
"Right-click on node shows menu: Delete",
"Right-click on edge shows menu: Delete, Add Condition",
"Clicking elsewhere or pressing Escape closes menu",
"Menu styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 30,
"passes": true,
"notes": ""
},
{
"id": "US-031",
"title": "Condition editor modal",
"description": "As a user, I want to add conditions to edges so branches depend on variables.",
"acceptanceCriteria": [
"Create components/editor/ConditionEditor.tsx modal/popover",
"Opens on double-click edge or via context menu 'Add Condition'",
"Form fields: variable name input, operator dropdown (>, <, ==, >=, <=, !=), value number input",
"Pre-fill fields if edge already has condition",
"Save button applies condition to edge data",
"Clear/Remove button removes condition from edge",
"Cancel button closes without saving",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 31,
"passes": true,
"notes": ""
},
{
"id": "US-032",
"title": "Display conditions on edges",
"description": "As a user, I want to see conditions displayed on edges.",
"acceptanceCriteria": [
"Create custom edge component or use edge labels",
"Edges with conditions render as dashed lines (strokeDasharray)",
"Condition label displayed on edge (e.g., 'score > 5')",
"Unconditional edges remain solid lines",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 32,
"passes": true,
"notes": ""
},
{
"id": "US-033",
"title": "Auto-save to LocalStorage",
"description": "As a user, I want my work auto-saved locally so I don't lose progress if the browser crashes.",
"acceptanceCriteria": [
"Save flowchart state (nodes + edges) to LocalStorage on every change",
"Debounce saves (e.g., 1 second delay after last change)",
"LocalStorage key format: 'vnwrite-draft-{projectId}'",
"On editor load, check LocalStorage for saved draft",
"If local draft exists and differs from database, show prompt to restore or discard",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 33,
"passes": true,
"notes": ""
},
{
"id": "US-034",
"title": "Save project to database",
"description": "As a user, I want to save my project to the database manually.",
"acceptanceCriteria": [
"Clicking 'Save' in toolbar saves current nodes/edges to Supabase",
"Update project's flowchart_data and updated_at fields",
"Show saving indicator/spinner while in progress",
"Show success toast on completion",
"Clear LocalStorage draft after successful save",
"Show error toast if save fails",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 34,
"passes": true,
"notes": ""
},
{
"id": "US-035",
"title": "Export project as .vnflow file",
"description": "As a user, I want to export my project as a JSON file for backup or sharing.",
"acceptanceCriteria": [
"Clicking 'Export' in toolbar triggers file download",
"File named '[project-name].vnflow'",
"File contains JSON with nodes and edges arrays",
"JSON is pretty-printed (2-space indent) for readability",
"Uses browser download API (create blob, trigger download)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 35,
"passes": true,
"notes": ""
},
{
"id": "US-036",
"title": "Import project from .vnflow file",
"description": "As a user, I want to import a .vnflow file to restore or share projects.",
"acceptanceCriteria": [
"Clicking 'Import' in toolbar opens file picker",
"Accept .vnflow and .json file extensions",
"If current project has unsaved changes, show confirmation dialog",
"Validate imported file has nodes and edges arrays",
"Show error toast if file is invalid",
"Load valid data into React Flow state (replaces current flowchart)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 36,
"passes": true,
"notes": ""
},
{
"id": "US-037",
"title": "Export to Ren'Py JSON format",
"description": "As a user, I want to export my flowchart to Ren'Py-compatible JSON for use in my game.",
"acceptanceCriteria": [
"Add 'Export to Ren'Py' option (button or dropdown item)",
"File named '[project-name]-renpy.json'",
"Dialogue nodes export as: { type: 'dialogue', speaker: '...', text: '...' }",
"Choice nodes export as: { type: 'menu', prompt: '...', choices: [{ label: '...', next: '...' }] }",
"Variable nodes export as: { type: 'variable', name: '...', operation: '...', value: ... }",
"Edges with conditions include condition object on the choice/jump",
"Organize nodes into labeled sections based on flow (traverse from first node)",
"Include metadata: projectName, exportedAt timestamp",
"Output JSON is valid (test with JSON.parse)",
"Typecheck passes"
],
"priority": 37,
"passes": true,
"notes": ""
},
{
"id": "US-038",
"title": "Unsaved changes warning",
"description": "As a user, I want a warning before losing unsaved work.",
"acceptanceCriteria": [
"Track dirty state: true when flowchart modified after last save",
"Set dirty=true on node/edge add, delete, or modify",
"Set dirty=false after successful save",
"Browser beforeunload event shows warning if dirty",
"Navigating to dashboard shows confirmation modal if dirty",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 38,
"passes": true,
"notes": ""
},
{
"id": "US-039",
"title": "Loading and error states",
"description": "As a user, I want clear feedback when things are loading or when errors occur.",
"acceptanceCriteria": [
"Loading spinner component for async operations",
"Editor shows loading spinner while fetching project",
"Error message displayed if project fails to load (with back to dashboard link)",
"Toast notification system for success/error messages",
"Save error shows toast with retry option",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 39,
"passes": true,
"notes": ""
},
{
"id": "US-040",
"title": "Conditionals on choice options",
"description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).",
"acceptanceCriteria": [
"Each ChoiceOption can have optional condition (variableName, operator, value)",
"Update ChoiceNode UI to show 'Add condition' button per option",
"Condition editor modal for each option",
"Visual indicator (icon/badge) on options with conditions",
"Update TypeScript types: ChoiceOption gets optional condition field",
"Export includes per-option conditions in Ren'Py JSON",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 40,
"passes": true,
"notes": "Dependencies: US-018, US-019, US-025. Complexity: M"
},
{
"id": "US-041",
"title": "Change password for logged-in user",
"description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.",
"acceptanceCriteria": [
"Settings/profile page accessible from dashboard header",
"Form with: current password, new password, confirm new password fields",
"Calls Supabase updateUser with new password",
"Requires current password verification (re-authenticate)",
"Shows success/error messages",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 41,
"passes": true,
"notes": "Dependencies: US-004. Complexity: S"
},
{
"id": "US-042",
"title": "Password reset modal on token arrival",
"description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.",
"acceptanceCriteria": [
"Detect password reset token in URL (from Supabase email link)",
"Show modal/dialog automatically when token present",
"Modal has: new password, confirm password fields",
"Calls Supabase updateUser with token to complete reset",
"On success, close modal and redirect to login",
"On error, show error message",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 42,
"passes": true,
"notes": "Dependencies: US-006. Complexity: S"
}
]
}
@@ -1,634 +0,0 @@
## Codebase Patterns
- Project uses Next.js 16 with App Router, TypeScript, and TailwindCSS 4
- Source files are in `src/` directory (app, components, lib, types)
- Supabase is configured with @supabase/supabase-js and @supabase/ssr packages
- Environment variables follow NEXT_PUBLIC_* convention for client-side access
- Use `npm run typecheck` to run TypeScript type checking (tsc --noEmit)
- Flowchart types exported from `src/types/flowchart.ts`
- Supabase migrations go in `supabase/migrations/` with timestamp prefix (YYYYMMDDHHMMSS_*.sql)
- Database has profiles table (linked to auth.users) and projects table (with flowchart_data JSONB)
- RLS policies enforce user_id = auth.uid() for project access
- Supabase client utilities in `src/lib/supabase/`: client.ts (browser), server.ts (App Router), middleware.ts (route protection)
- Next.js middleware.ts at project root handles route protection using updateSession helper
- Public auth routes: /login, /signup, /forgot-password, /reset-password
- Protected routes: /dashboard, /editor/* (redirect to /login if unauthenticated)
- Auth pages use 'use client' with useState, createClient() from lib/supabase/client.ts, and useRouter for redirects
- For lists with client-side updates (delete/add), use wrapper client component that receives initialData from server component
- Toast component in `src/components/Toast.tsx` for success/error notifications (auto-dismiss after 3s)
- Admin operations use SUPABASE_SERVICE_ROLE_KEY (server-side only via server actions)
- Admin users have is_admin=true in profiles table; check via .select('is_admin').eq('id', user.id).single()
- React Flow editor is in `src/app/editor/[projectId]/` with page.tsx (server) and FlowchartEditor.tsx (client)
- React Flow requires 'use client' and importing 'reactflow/dist/style.css'
- Use toReactFlowNodes/toReactFlowEdges helpers to convert app types to React Flow types
- Custom node components go in `src/components/editor/nodes/` with NodeProps<T> typing and useReactFlow() for updates
- Register custom node types in nodeTypes object (memoized with useMemo) and pass to ReactFlow component
- FlowchartEditor uses ReactFlowProvider wrapper + inner component pattern for useReactFlow() hook access
- Use nanoid for generating unique node IDs (import from 'nanoid')
- Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message
- Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons
- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
---
## 2026-01-21 - US-001
- What was implemented: Project scaffolding and configuration
- 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
- **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
---
## 2026-01-21 - US-002
- What was implemented: TypeScript types for flowchart data structures
- Files changed:
- src/types/flowchart.ts - new file with all flowchart type definitions
- package.json - added typecheck script (tsc --noEmit)
- **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
---
## 2026-01-21 - US-003
- What was implemented: Supabase schema for users and projects
- Files changed:
- supabase/migrations/20260121000000_create_profiles_and_projects.sql - new file with all database schema
- **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
---
## 2026-01-21 - US-004
- What was implemented: Supabase client configuration utilities
- 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)
- **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
---
## 2026-01-21 - US-005
- What was implemented: Protected routes middleware for authentication
- Files changed:
- middleware.ts - new file at project root for route protection
- **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
---
## 2026-01-21 - US-006
- What was implemented: Login page with email/password authentication
- Files changed:
- src/app/login/page.tsx - new file with login form and Supabase auth
- **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)
---
## 2026-01-21 - US-007
- What was implemented: Sign up page for invite-only account setup
- Files changed:
- src/app/signup/page.tsx - new file with signup form and Supabase auth
- **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])
---
## 2026-01-21 - US-008
- What was implemented: Logout functionality component
- Files changed:
- src/components/LogoutButton.tsx - new client component with signOut and redirect
- src/components/.gitkeep - removed (no longer needed)
- **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
---
## 2026-01-21 - US-009
- What was implemented: Password reset - forgot password page
- Files changed:
- src/app/forgot-password/page.tsx - new file with forgot password form and email reset
- **Learnings for future iterations:**
- resetPasswordForEmail requires redirectTo option to specify where user lands after clicking reset link
- Use `window.location.origin` to get the current site URL for redirectTo
- Page shows different UI after success (conditional rendering with success state)
- Use &apos; for apostrophe in JSX to avoid HTML entity issues
- Follow same styling pattern as login page for consistency across auth pages
---
## 2026-01-21 - US-010
- What was implemented: Password reset - set new password page
- 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
- **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
---
## 2026-01-22 - US-030
- What was implemented: Right-click context menu for canvas, nodes, and edges
- Files changed:
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
- **Learnings for future iterations:**
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
---
## 2026-01-22 - US-031
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
- Files changed:
- src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers
- **Learnings for future iterations:**
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
- Use `edge.data.condition` to access condition object on edges
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
---
## 2026-01-22 - US-032
- What was implemented: Display conditions on edges with dashed styling and labels
- Files changed:
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
- **Learnings for future iterations:**
- Custom React Flow edges use EdgeProps<T> typing where T is the data shape
- Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels
- `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels
- Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow
- Style edges with conditions using strokeDasharray: '5 5' for dashed lines
- Custom edges go in `src/components/editor/edges/` directory
- Use amber color scheme for conditional edges to distinguish from regular edges
---
## 2026-01-22 - US-033
- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI
- **Learnings for future iterations:**
- Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning
- LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts
- Debounce saves with 1 second delay using useRef for timer tracking
- Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges)
- React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`)
- Check `typeof window === 'undefined'` in lazy initializer for SSR safety
- clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save
- JSON.stringify comparison works for flowchart data equality check
---
## 2026-01-22 - US-034
- What was implemented: Save project to database functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications
- src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator
- **Learnings for future iterations:**
- Use createClient() from lib/supabase/client.ts for browser-side database operations
- Supabase update returns { error } object for error handling
- Use async/await with try/catch for async save operations
- Set updated_at manually with new Date().toISOString() for Supabase JSONB updates
- Clear LocalStorage draft after successful save to avoid stale drafts
- Toast state uses object with message and type for flexibility
- Loading spinner SVG with animate-spin class for visual feedback during save
---
## 2026-01-22 - US-035
- What was implemented: Export project as .vnflow file functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop
- src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor
- **Learnings for future iterations:**
- Use Blob with type 'application/json' for JSON file downloads
- JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation
- URL.createObjectURL creates a temporary URL for the blob
- Create temporary anchor element with download attribute to trigger file download
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
---
## 2026-01-22 - US-036
- What was implemented: Import project from .vnflow file functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes
- **Learnings for future iterations:**
- Use hidden `<input type="file">` element with ref to trigger file picker programmatically via `ref.current?.click()`
- Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"`
- Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file
- Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks
- Type guard function `isValidFlowchartData()` validates imported JSON structure before loading
- Track unsaved changes by comparing current state to initialData using JSON.stringify comparison
- Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss
---
## 2026-01-22 - US-037
- What was implemented: Export to Ren'Py JSON format functionality
- Files changed:
- src/components/editor/Toolbar.tsx - added 'Export to Ren'Py' button with purple styling
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented Ren'Py export types, conversion functions, and handleExportRenpy callback
- **Learnings for future iterations:**
- Ren'Py export uses typed interfaces for different node types: RenpyDialogueNode, RenpyMenuNode, RenpyVariableNode
- Find first node by identifying nodes with no incoming edges (not in any edge's target set)
- Use graph traversal (DFS) to organize nodes into labeled sections based on flow
- Choice nodes create branching sections - save current section before processing each branch
- Track visited nodes to detect cycles and create proper labels for jump references
- Labels are generated based on speaker name or incremental counter for uniqueness
- Replace node IDs with proper labels in a second pass after traversal completes
- Include metadata (projectName, exportedAt) at the top level of the export
- Validate JSON output with JSON.parse before download to ensure validity
- Use purple color scheme for Ren'Py-specific button to distinguish from generic export
---
## 2026-01-22 - US-038
- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component
- src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access)
- **Learnings for future iterations:**
- Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef
- lastSavedDataRef is a useRef initialized with initialData and updated after successful save
- Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers
- Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state
- Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal
- Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action
- "(unsaved changes)" indicator shown next to project name when isDirty is true
---
## 2026-01-22 - US-039
- What was implemented: Loading and error states with reusable spinner, error page, and toast retry
- Files changed:
- src/components/LoadingSpinner.tsx - new reusable loading spinner component with size variants and optional message
- src/app/editor/[projectId]/loading.tsx - updated to use LoadingSpinner component
- src/app/editor/[projectId]/page.tsx - replaced notFound() with custom error UI showing "Project Not Found" with back to dashboard link
- src/components/Toast.tsx - added optional action prop for action buttons (e.g., retry)
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated toast state type to include action, save error now shows retry button via handleSaveRef pattern
- **Learnings for future iterations:**
- Use a ref (handleSaveRef) to break circular dependency when a useCallback needs to reference itself for retry logic
- Toast action prop uses `{ label: string; onClick: () => void }` for flexible action buttons
- Don't auto-dismiss toasts that have action buttons (users need time to click them)
- Replace `notFound()` with inline error UI when you need custom styling and navigation links
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
- Link component from next/link is needed in server components for navigation (no useRouter in server components)
---
## 2026-01-22 - US-040
- What was implemented: Conditionals on choice options - per-option visibility conditions
- Files changed:
- src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption
- src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management
- src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value)
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition)
- **Learnings for future iterations:**
- Per-option conditions use the same Condition type as edge conditions
- Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it
- Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings
- OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs
- In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility
- Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not
- Condition badge below option shows "if variableName operator value" text in compact format
---
## 2026-01-22 - US-041
- What was implemented: Change password for logged-in user from settings page
- Files changed:
- src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields)
- src/components/Navbar.tsx - added "Settings" link to navbar
- **Learnings for future iterations:**
- Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check)
- Re-authentication uses signInWithPassword with current password before allowing updateUser
- Supabase getUser() returns current user email needed for re-auth signInWithPassword call
- Password validation: check match and minimum length (6 chars) before making API calls
- Clear form fields after successful password update for security
- Settings link in navbar uses neutral zinc colors to distinguish from admin/action links
---
## 2026-01-22 - US-042
- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL
- Files changed:
- src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form
- src/app/login/page.tsx - integrated PasswordResetModal component on the login page
- **Learnings for future iterations:**
- PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens
- Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh)
- Separate tokenError state from form error state to show different UI (expired link vs. form validation)
- Modal uses fixed positioning with z-50 to overlay above page content
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
---
+50 -1
View File
@@ -14,7 +14,8 @@
"next": "16.1.4", "next": "16.1.4",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"reactflow": "^11.11.4" "reactflow": "^11.11.4",
"yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -4975,6 +4976,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/iterator.prototype": { "node_modules/iterator.prototype": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -5130,6 +5141,27 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"license": "MIT",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -7186,6 +7218,23 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yjs": {
"version": "13.6.29",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2 -1
View File
@@ -16,7 +16,8 @@
"next": "16.1.4", "next": "16.1.4",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"reactflow": "^11.11.4" "reactflow": "^11.11.4",
"yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+12 -354
View File
@@ -216,7 +216,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 12, "priority": 12,
"passes": false, "passes": true,
"notes": "Dependencies: US-058, US-059, US-060, US-061" "notes": "Dependencies: US-058, US-059, US-060, US-061"
}, },
{ {
@@ -232,7 +232,7 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 13, "priority": 13,
"passes": false, "passes": true,
"notes": "" "notes": ""
}, },
{ {
@@ -251,7 +251,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 14, "priority": 14,
"passes": false, "passes": true,
"notes": "Dependencies: US-043" "notes": "Dependencies: US-043"
}, },
{ {
@@ -269,7 +269,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 15, "priority": 15,
"passes": false, "passes": true,
"notes": "Dependencies: US-043" "notes": "Dependencies: US-043"
}, },
{ {
@@ -287,7 +287,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 16, "priority": 16,
"passes": false, "passes": true,
"notes": "Dependencies: US-045" "notes": "Dependencies: US-045"
}, },
{ {
@@ -306,7 +306,7 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 17, "priority": 17,
"passes": false, "passes": true,
"notes": "Dependencies: US-045" "notes": "Dependencies: US-045"
}, },
{ {
@@ -324,7 +324,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 18, "priority": 18,
"passes": false, "passes": true,
"notes": "Dependencies: US-045, US-046" "notes": "Dependencies: US-045, US-046"
}, },
{ {
@@ -341,7 +341,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 19, "priority": 19,
"passes": false, "passes": true,
"notes": "Dependencies: US-045, US-046" "notes": "Dependencies: US-045, US-046"
}, },
{ {
@@ -359,7 +359,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 20, "priority": 20,
"passes": false, "passes": true,
"notes": "Dependencies: US-045, US-048" "notes": "Dependencies: US-045, US-048"
}, },
{ {
@@ -376,7 +376,7 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 21, "priority": 21,
"passes": false, "passes": true,
"notes": "Dependencies: US-043, US-048" "notes": "Dependencies: US-043, US-048"
}, },
{ {
@@ -394,7 +394,7 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 22, "priority": 22,
"passes": false, "passes": true,
"notes": "Dependencies: US-051" "notes": "Dependencies: US-051"
}, },
{ {
@@ -414,350 +414,8 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 23, "priority": 23,
<<<<<<< HEAD "passes": true,
"passes": false,
"notes": "Dependencies: US-052, US-048" "notes": "Dependencies: US-052, US-048"
=======
"passes": true,
"notes": ""
},
{
"id": "US-024",
"title": "Add/remove choice options",
"description": "As a user, I want to add or remove choice options (2-6 options supported).",
"acceptanceCriteria": [
"ChoiceNode has '+' button to add new option",
"Maximum 6 options (button disabled or hidden at max)",
"Each option has 'x' button to remove it",
"Minimum 2 options (remove button disabled or hidden at min)",
"Adding option creates new output Handle dynamically",
"Removing option removes its Handle",
"Node data updates in React Flow state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 24,
"passes": true,
"notes": ""
},
{
"id": "US-025",
"title": "Create custom variable node component",
"description": "As a user, I want variable nodes to set or modify story variables.",
"acceptanceCriteria": [
"Create components/editor/nodes/VariableNode.tsx",
"Node styled with orange background/border",
"Displays editable input for variable name (placeholder: 'variableName')",
"Displays dropdown/select for operation: set, add, subtract",
"Displays editable number input for value (default: 0)",
"Has one Handle at top (type='target', id='input')",
"Has one Handle at bottom (type='source', id='output')",
"Register as custom node type in React Flow",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 25,
"passes": true,
"notes": ""
},
{
"id": "US-026",
"title": "Add variable node from toolbar",
"description": "As a user, I want to add variable nodes by clicking the toolbar button.",
"acceptanceCriteria": [
"Clicking 'Add Variable' in toolbar creates new VariableNode",
"Node appears at center of current viewport",
"Node has unique ID",
"Node initialized with empty variableName, operation='set', value=0",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 26,
"passes": true,
"notes": ""
},
{
"id": "US-027",
"title": "Connect nodes with edges",
"description": "As a user, I want to connect nodes with arrows to define story flow.",
"acceptanceCriteria": [
"Dragging from source Handle to target Handle creates edge (React Flow default)",
"Edges render as smooth bezier curves (default edge type or smoothstep)",
"Edges show arrow marker indicating direction (markerEnd)",
"Edges update position when nodes are moved",
"Cannot connect source-to-source or target-to-target (React Flow handles this)",
"New edges added to React Flow edges state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 27,
"passes": true,
"notes": ""
},
{
"id": "US-028",
"title": "Select and delete nodes",
"description": "As a user, I want to delete nodes to revise my flowchart.",
"acceptanceCriteria": [
"Clicking a node selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected node(s)",
"Deleting node also removes all connected edges",
"Use onNodesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 28,
"passes": true,
"notes": ""
},
{
"id": "US-029",
"title": "Select and delete edges",
"description": "As a user, I want to delete connections between nodes.",
"acceptanceCriteria": [
"Clicking an edge selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected edge(s)",
"Use onEdgesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 29,
"passes": true,
"notes": ""
},
{
"id": "US-030",
"title": "Right-click context menu",
"description": "As a user, I want a context menu for quick actions.",
"acceptanceCriteria": [
"Create components/editor/ContextMenu.tsx",
"Right-click on canvas shows menu: Add Dialogue, Add Choice, Add Variable",
"New node created at click position",
"Right-click on node shows menu: Delete",
"Right-click on edge shows menu: Delete, Add Condition",
"Clicking elsewhere or pressing Escape closes menu",
"Menu styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 30,
"passes": true,
"notes": ""
},
{
"id": "US-031",
"title": "Condition editor modal",
"description": "As a user, I want to add conditions to edges so branches depend on variables.",
"acceptanceCriteria": [
"Create components/editor/ConditionEditor.tsx modal/popover",
"Opens on double-click edge or via context menu 'Add Condition'",
"Form fields: variable name input, operator dropdown (>, <, ==, >=, <=, !=), value number input",
"Pre-fill fields if edge already has condition",
"Save button applies condition to edge data",
"Clear/Remove button removes condition from edge",
"Cancel button closes without saving",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 31,
"passes": true,
"notes": ""
},
{
"id": "US-032",
"title": "Display conditions on edges",
"description": "As a user, I want to see conditions displayed on edges.",
"acceptanceCriteria": [
"Create custom edge component or use edge labels",
"Edges with conditions render as dashed lines (strokeDasharray)",
"Condition label displayed on edge (e.g., 'score > 5')",
"Unconditional edges remain solid lines",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 32,
"passes": true,
"notes": ""
},
{
"id": "US-033",
"title": "Auto-save to LocalStorage",
"description": "As a user, I want my work auto-saved locally so I don't lose progress if the browser crashes.",
"acceptanceCriteria": [
"Save flowchart state (nodes + edges) to LocalStorage on every change",
"Debounce saves (e.g., 1 second delay after last change)",
"LocalStorage key format: 'vnwrite-draft-{projectId}'",
"On editor load, check LocalStorage for saved draft",
"If local draft exists and differs from database, show prompt to restore or discard",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 33,
"passes": true,
"notes": ""
},
{
"id": "US-034",
"title": "Save project to database",
"description": "As a user, I want to save my project to the database manually.",
"acceptanceCriteria": [
"Clicking 'Save' in toolbar saves current nodes/edges to Supabase",
"Update project's flowchart_data and updated_at fields",
"Show saving indicator/spinner while in progress",
"Show success toast on completion",
"Clear LocalStorage draft after successful save",
"Show error toast if save fails",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 34,
"passes": true,
"notes": ""
},
{
"id": "US-035",
"title": "Export project as .vnflow file",
"description": "As a user, I want to export my project as a JSON file for backup or sharing.",
"acceptanceCriteria": [
"Clicking 'Export' in toolbar triggers file download",
"File named '[project-name].vnflow'",
"File contains JSON with nodes and edges arrays",
"JSON is pretty-printed (2-space indent) for readability",
"Uses browser download API (create blob, trigger download)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 35,
"passes": true,
"notes": ""
},
{
"id": "US-036",
"title": "Import project from .vnflow file",
"description": "As a user, I want to import a .vnflow file to restore or share projects.",
"acceptanceCriteria": [
"Clicking 'Import' in toolbar opens file picker",
"Accept .vnflow and .json file extensions",
"If current project has unsaved changes, show confirmation dialog",
"Validate imported file has nodes and edges arrays",
"Show error toast if file is invalid",
"Load valid data into React Flow state (replaces current flowchart)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 36,
"passes": true,
"notes": ""
},
{
"id": "US-037",
"title": "Export to Ren'Py JSON format",
"description": "As a user, I want to export my flowchart to Ren'Py-compatible JSON for use in my game.",
"acceptanceCriteria": [
"Add 'Export to Ren'Py' option (button or dropdown item)",
"File named '[project-name]-renpy.json'",
"Dialogue nodes export as: { type: 'dialogue', speaker: '...', text: '...' }",
"Choice nodes export as: { type: 'menu', prompt: '...', choices: [{ label: '...', next: '...' }] }",
"Variable nodes export as: { type: 'variable', name: '...', operation: '...', value: ... }",
"Edges with conditions include condition object on the choice/jump",
"Organize nodes into labeled sections based on flow (traverse from first node)",
"Include metadata: projectName, exportedAt timestamp",
"Output JSON is valid (test with JSON.parse)",
"Typecheck passes"
],
"priority": 37,
"passes": true,
"notes": ""
},
{
"id": "US-038",
"title": "Unsaved changes warning",
"description": "As a user, I want a warning before losing unsaved work.",
"acceptanceCriteria": [
"Track dirty state: true when flowchart modified after last save",
"Set dirty=true on node/edge add, delete, or modify",
"Set dirty=false after successful save",
"Browser beforeunload event shows warning if dirty",
"Navigating to dashboard shows confirmation modal if dirty",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 38,
"passes": true,
"notes": ""
},
{
"id": "US-039",
"title": "Loading and error states",
"description": "As a user, I want clear feedback when things are loading or when errors occur.",
"acceptanceCriteria": [
"Loading spinner component for async operations",
"Editor shows loading spinner while fetching project",
"Error message displayed if project fails to load (with back to dashboard link)",
"Toast notification system for success/error messages",
"Save error shows toast with retry option",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 39,
"passes": true,
"notes": ""
},
{
"id": "US-040",
"title": "Conditionals on choice options",
"description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).",
"acceptanceCriteria": [
"Each ChoiceOption can have optional condition (variableName, operator, value)",
"Update ChoiceNode UI to show 'Add condition' button per option",
"Condition editor modal for each option",
"Visual indicator (icon/badge) on options with conditions",
"Update TypeScript types: ChoiceOption gets optional condition field",
"Export includes per-option conditions in Ren'Py JSON",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 40,
"passes": true,
"notes": "Dependencies: US-018, US-019, US-025. Complexity: M"
},
{
"id": "US-041",
"title": "Change password for logged-in user",
"description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.",
"acceptanceCriteria": [
"Settings/profile page accessible from dashboard header",
"Form with: current password, new password, confirm new password fields",
"Calls Supabase updateUser with new password",
"Requires current password verification (re-authenticate)",
"Shows success/error messages",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 41,
"passes": true,
"notes": "Dependencies: US-004. Complexity: S"
},
{
"id": "US-042",
"title": "Password reset modal on token arrival",
"description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.",
"acceptanceCriteria": [
"Detect password reset token in URL (from Supabase email link)",
"Show modal/dialog automatically when token present",
"Modal has: new password, confirm password fields",
"Calls Supabase updateUser with token to complete reset",
"On success, close modal and redirect to login",
"On error, show error message",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 42,
"passes": true,
"notes": "Dependencies: US-006. Complexity: S"
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
} }
] ]
} }
+126 -3
View File
@@ -27,7 +27,6 @@
- Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message - 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 - 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 - Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
<<<<<<< HEAD
- Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts` - 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 - `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 - Use `useEditorContext()` in node components to access project-level characters and variables without prop drilling through React Flow node data
@@ -44,8 +43,33 @@
- `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions. - `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. - 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. - 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.
======= - Collaboration tables: `project_collaborators` (roles), `collaboration_sessions` (presence), `audit_trail` (history) — all with RLS scoped by project ownership or collaborator membership
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 - RLS pattern for shared resources: check `projects.user_id = auth.uid()` OR `project_collaborators.user_id = auth.uid()` to cover both owners and collaborators
- `RealtimeConnection` class at `src/lib/collaboration/realtime.ts` manages Supabase Realtime channel lifecycle (connect, heartbeat, reconnect, disconnect). Instantiate with (projectId, userId, callbacks).
- FlowchartEditor receives `userId` prop from page.tsx server component for collaboration features
- Toolbar accepts optional `connectionState` prop to show green/yellow/red connection indicator
- `collaboration_sessions` table has UNIQUE(project_id, user_id) constraint to support upsert-based session management
- Server actions for project-specific operations go in `src/app/editor/[projectId]/actions.ts` — use `'use server'` directive and return `{ success: boolean; error?: string }` pattern
- Editor page.tsx supports both owner and collaborator access: first checks ownership, then falls back to `project_collaborators` lookup. Pass `isOwner` prop to client component.
- `ShareModal` at `src/components/editor/ShareModal.tsx` manages collaborator invites/roles/removal via server actions. Only owners see invite form.
- Dashboard shared projects use Supabase join query: `project_collaborators.select('role, projects(id, name, updated_at)')` to fetch projects shared with the user
- `ProjectCard` supports optional `shared` and `sharedRole` props — when `shared=true`, hide edit/delete buttons and show role badge instead
- `PresenceAvatars` at `src/components/editor/PresenceAvatars.tsx` renders connected collaborator avatars. Receives `PresenceUser[]` from `RealtimeConnection.onPresenceSync`.
- `RealtimeConnection` constructor takes `(projectId, userId, displayName, callbacks)` — `displayName` is broadcast via Supabase Realtime presence tracking
- User color for presence is derived from a hash of their userId, ensuring consistency across sessions. Use `getUserColor(userId)` pattern from PresenceAvatars.
- `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map<string> for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates.
- CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops.
- For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive.
- For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed
- `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref.
- Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events.
- `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot.
- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user.
- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release.
- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor).
- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. Exports `AuditEntry` type for consumers.
- To prevent double audit recording when programmatically changing nodes/edges (e.g., revert), set a ref guard (`isRevertingRef`) before `setNodes`/`setEdges` and clear it with `setTimeout(() => ..., 0)`. Check the guard in the CRDT sync effects before calling `auditRef.current.recordNodeChanges()`.
- For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect.
--- ---
@@ -382,3 +406,102 @@
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page) - After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns - The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
--- ---
## 2026-01-23 - US-047
- What was implemented: Live cursor positions on canvas showing collaborators' mouse positions in real-time
- Files changed:
- `src/lib/collaboration/realtime.ts` - Added `CursorPosition`, `RemoteCursor` types, `onCursorUpdate` callback to `RealtimeCallbacks`, broadcast listener for 'cursor' events, and `broadcastCursor()` method
- `src/components/editor/RemoteCursors.tsx` - New component: renders colored arrow cursors with user name labels, smooth position interpolation via CSS transition, 5-second fade-out for inactive cursors, flow-to-screen coordinate transformation using React Flow viewport
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `remoteCursors` state, `cursorThrottleRef` for 50ms throttling, `handleMouseMove` that converts screen→flow coordinates and broadcasts via RealtimeConnection, cleanup of cursors for disconnected users, rendering of RemoteCursors overlay
- `src/app/editor/[projectId]/page.tsx` - Fixed broken JSX structure (malformed HTML nesting and dead code after return)
- **Learnings for future iterations:**
- `screenToFlowPosition` from `useReactFlow()` converts screen-relative mouse coordinates to flow coordinates; for the reverse (rendering cursors), multiply by viewport.zoom and add viewport offset
- Cursor broadcast uses Supabase Realtime broadcast (not presence) for efficiency: `channel.send({ type: 'broadcast', event: 'cursor', payload })`. Broadcast is fire-and-forget (no persistence).
- React Compiler lint treats `Date.now()` as an impure function call — use `useState(() => Date.now())` lazy initializer pattern instead of `useState(Date.now())`
- Throttling mouse events uses a ref storing the last broadcast timestamp (`cursorThrottleRef`), checked at the start of the handler before computing flow position
- Remote cursors are removed when their user disconnects (filtered by `presenceUsers` list changes)
- CSS `transition: transform 80ms linear` provides smooth interpolation between position updates without needing requestAnimationFrame
- The `page.tsx` had a corrupted structure with unclosed tags and dead code — likely from a failed merge. Fixed by restructuring the error/not-found case into a proper early return
- No browser testing tools are available; manual verification is needed.
---
## 2026-01-23 - US-050
- What was implemented: Join/leave toast notifications when collaborators connect or disconnect from the editing session
- Files changed:
- `src/lib/collaboration/realtime.ts` - Added `onPresenceJoin` and `onPresenceLeave` callbacks to `RealtimeCallbacks` type; added Supabase Realtime `presence.join` and `presence.leave` event listeners that filter out own user and invoke callbacks
- `src/components/editor/CollaborationToast.tsx` - New component: renders a compact toast notification with user's presence color dot, "[Name] joined" or "[Name] left" message, auto-dismisses after 3 seconds
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `getUserColor` helper (same hash logic as PresenceAvatars), `collaborationNotifications` state, `onPresenceJoin`/`onPresenceLeave` handlers on RealtimeConnection, `handleDismissNotification` callback, and rendering of CollaborationToast list in bottom-left corner
- **Learnings for future iterations:**
- Supabase Realtime presence has three event types: `sync` (full state), `join` (new arrivals), and `leave` (departures). Each provides an array of presences (`newPresences`/`leftPresences`). Use all three for different purposes.
- The `join` event fires for each newly tracked presence. It includes the presence payload (userId, displayName) that was passed to `channel.track()`.
- Collaboration notifications are positioned `bottom-left` (`left-4`) to avoid overlapping with the existing Toast component which is `bottom-right` (`right-4`).
- The `getUserColor` function is duplicated from PresenceAvatars to avoid circular imports. Both use the same hash-to-color-index algorithm with the same RANDOM_COLORS palette for consistency.
- No browser testing tools are available; manual verification is needed.
---
## 2026-01-24 - US-049
- What was implemented: Node editing lock indicators that show when another collaborator is editing a node
- Files changed:
- `src/lib/collaboration/realtime.ts` - Added `NodeLock` type, `onNodeLockUpdate` callback to `RealtimeCallbacks`, `node-lock` broadcast listener, and `broadcastNodeLock()` method
- `src/components/editor/NodeLockIndicator.tsx` - New component: renders a colored border and name label overlay on locked nodes
- `src/components/editor/EditorContext.tsx` - Extended context with `NodeLockInfo` type, `nodeLocks` (Map), `onNodeFocus`, and `onNodeBlur` callbacks
- `src/components/editor/nodes/DialogueNode.tsx` - Added lock detection, `NodeLockIndicator` rendering, "Being edited by [name]" overlay, `onFocus`/`onBlur` handlers
- `src/components/editor/nodes/VariableNode.tsx` - Same lock indicator pattern as DialogueNode
- `src/components/editor/nodes/ChoiceNode.tsx` - Same lock indicator pattern as DialogueNode
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `nodeLocks` state (Map<string, NodeLockInfo>), `localLockRef` for tracking own lock, `handleNodeFocus`/`handleNodeBlur` callbacks, `onNodeLockUpdate` handler in RealtimeConnection, lock expiry timer (60s check every 5s), lock cleanup on user leave and component unmount, extended `editorContextValue` with lock state
- **Learnings for future iterations:**
- Node lock uses Supabase Realtime broadcast (like cursors) — ephemeral, not persisted to DB. Event name: `node-lock`.
- Lock broadcasting uses `nodeId: string | null` pattern: non-null to acquire lock, `null` to release. The receiving side maps userId to their current lock.
- Lock expiry uses a 60-second timeout checked every 5 seconds via `setInterval`. The `lockedAt` timestamp is broadcast with the lock payload.
- Each node component accesses lock state via `EditorContext` (`nodeLocks` Map). The `NodeLockInfo` type extends `NodeLock` with a `color` field derived from `getUserColor()`.
- `onFocus`/`onBlur` on the node container div fires when any child input gains/loses focus (focus event bubbles), which naturally maps to "user is editing this node".
- Lock release on disconnect: broadcast `null` lock before calling `connection.disconnect()` in the cleanup return of the mount effect.
- Locks from disconnected users are cleaned up in the same effect that removes cursors (filtered by `presenceUsers` list).
- No browser testing tools are available; manual verification is needed.
---
## 2026-01-24 - US-051
- What was implemented: Audit trail recording that writes all node/edge add/update/delete operations to the `audit_trail` table with debouncing and fire-and-forget semantics
- Files changed:
- `src/lib/collaboration/auditTrail.ts` - New `AuditTrailRecorder` class: tracks previous node/edge state, diffs against current state to detect add/update/delete operations, debounces writes per entity (1 second), merges rapid sequential actions (e.g., add+update=add, add+delete=no-op), fire-and-forget Supabase inserts with error logging, flush on destroy
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `auditRef` (AuditTrailRecorder), initialized in mount effect alongside CRDT manager, records node/edge changes in the CRDT sync effects (only for local changes, skipped for remote updates), destroyed on unmount
- **Learnings for future iterations:**
- The audit recorder piggybacks on the same CRDT sync effects that already compute `nodesForCRDT`/`edgesForCRDT` — this avoids duplicating the React Flow → FlowchartNode/Edge conversion.
- The `isRemoteUpdateRef` guard in the sync effects ensures audit entries are only created for local user actions, not for changes received from other collaborators (those users' own recorders will handle their audit entries).
- Debouncing per entity (1 second) prevents rapid edits (e.g., typing in a text field) from flooding the audit table. The merge logic handles transient states (add+delete within 1s = skip).
- The `destroy()` method flushes pending entries synchronously on unmount, ensuring in-flight edits aren't lost when navigating away.
- Supabase `.insert().then()` pattern provides fire-and-forget writes with error logging — the async operation doesn't block the editing flow.
- No browser testing needed — this is a developer/infrastructure story with no UI changes.
---
## 2026-01-24 - US-052
- What was implemented: Activity history sidebar that displays audit trail entries, grouped by time period, with entity selection on click
- Files changed:
- `src/components/editor/ActivityHistorySidebar.tsx` - New component: right sidebar panel showing chronological audit trail entries, grouped by Today/Yesterday/Earlier, with user color accents, entity descriptions, paginated loading (20 per page), and click-to-select entity on canvas
- `src/components/editor/Toolbar.tsx` - Added `onHistory` prop and "History" button in the right toolbar section
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `showHistory` state, `handleHistorySelectEntity` callback (selects nodes/edges on canvas), `ActivityHistorySidebar` import and rendering inside the canvas area, `onHistory` toggle prop on Toolbar
- **Learnings for future iterations:**
- React Compiler lint (`react-hooks/set-state-in-effect`) treats any function that calls setState as problematic when invoked inside useEffect — even if the setState is in an async `.then()` callback. To avoid this, extract data fetching into a pure function that returns a result object, then handle setState only in the `.then()` callback after checking a mounted/aborted guard.
- For right sidebar panels overlaying the canvas, use `absolute right-0 top-0 z-40 h-full w-80` inside the `relative flex-1` canvas container. This keeps the sidebar within the canvas area without affecting the toolbar.
- The `audit_trail` table has an index on `(project_id, created_at DESC)` which makes paginated queries efficient. Use `.range(offset, offset + PAGE_SIZE - 1)` for Supabase pagination.
- Entity descriptions are derived from `new_state` (for adds/updates) or `previous_state` (for deletes). The state contains the full node/edge data including `type`, `data.speaker`, `data.question`, `data.variableName`.
- Deleted entities (`action_type.endsWith('_delete')`) cannot be selected on canvas since they no longer exist — render those entries as disabled (no click handler, reduced opacity).
- No browser testing tools are available; manual verification is needed.
---
## 2026-01-24 - US-053
- What was implemented: Revert changes from audit trail — each history entry has a Revert button, confirmation dialog with before/after preview, and revert logic that reverses node/edge add/update/delete operations
- Files changed:
- `src/components/editor/RevertConfirmDialog.tsx` - New component: confirmation dialog showing action description, before/after state previews, with Cancel and Revert buttons
- `src/components/editor/ActivityHistorySidebar.tsx` - Added Revert button (shows on hover per entry), `revertEntry` state, `RevertConfirmDialog` rendering, exported `AuditEntry` type, added `onRevert` prop
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `handleRevertEntry` callback implementing all revert cases (node add→delete, node update→restore, node delete→recreate, same for edges), `isRevertingRef` to prevent double audit recording, `getRevertActionType` helper, explicit audit trail write for the revert itself, toast notification on success
- **Learnings for future iterations:**
- Reverting uses the same `setNodes`/`setEdges` state setters, so CRDT sync happens automatically through the existing effects that watch `nodesForCRDT`/`edgesForCRDT` — no explicit CRDT call needed.
- To prevent double audit recording (once from the sync effect, once from the explicit revert write), use an `isRevertingRef` guard set synchronously before `setNodes`/`setEdges` and cleared with `setTimeout(() => ..., 0)` after React processes the state updates.
- The revert audit entry uses inverse action types: reverting `node_add` records `node_delete`, reverting `node_delete` records `node_add`, `node_update` stays `node_update`.
- The `previous_state` and `new_state` are swapped for the revert audit entry: what was `new_state` (the current state being reverted) becomes `previous_state`, and what was `previous_state` (the state being restored) becomes `new_state`.
- Reverting a `node_add` also removes connected edges to prevent dangling edge references.
- The RevertConfirmDialog uses `z-[70]` to layer above the ActivityHistorySidebar (`z-40`).
- The Revert button uses CSS `group-hover:inline-block` pattern to appear only on entry hover, keeping the UI clean.
- No browser testing tools are available; manual verification is needed.
---
+36
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import NewProjectButton from '@/components/NewProjectButton' import NewProjectButton from '@/components/NewProjectButton'
import ProjectList from '@/components/ProjectList' import ProjectList from '@/components/ProjectList'
import ProjectCard from '@/components/ProjectCard'
export default async function DashboardPage() { export default async function DashboardPage() {
const supabase = await createClient() const supabase = await createClient()
@@ -19,6 +20,21 @@ export default async function DashboardPage() {
.eq('user_id', user.id) .eq('user_id', user.id)
.order('updated_at', { ascending: false }) .order('updated_at', { ascending: false })
// Fetch shared projects (projects where this user is a collaborator)
const { data: collaborations } = await supabase
.from('project_collaborators')
.select('role, projects(id, name, updated_at)')
.eq('user_id', user.id)
const sharedProjects = (collaborations || [])
.filter((c) => c.projects)
.map((c) => ({
...(c.projects as unknown as { id: string; name: string; updated_at: string }),
shared: true,
role: c.role,
}))
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
if (error) { if (error) {
return ( return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20"> <div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
@@ -44,6 +60,26 @@ export default async function DashboardPage() {
</div> </div>
<ProjectList initialProjects={projects || []} /> <ProjectList initialProjects={projects || []} />
{sharedProjects.length > 0 && (
<div className="mt-10">
<h2 className="mb-4 text-xl font-bold text-zinc-900 dark:text-zinc-50">
Shared with me
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sharedProjects.map((project) => (
<ProjectCard
key={project.id}
id={project.id}
name={project.name}
updatedAt={project.updated_at}
shared
sharedRole={project.role}
/>
))}
</div>
</div>
)}
</div> </div>
) )
} }
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
'use server'
import { createClient } from '@/lib/supabase/server'
export type Collaborator = {
id: string
user_id: string
role: 'owner' | 'editor' | 'viewer'
invited_at: string
accepted_at: string | null
display_name: string | null
email: string | null
}
export async function getCollaborators(
projectId: string
): Promise<{ success: boolean; data?: Collaborator[]; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.single()
if (!project) {
return { success: false, error: 'Project not found' }
}
const isOwner = project.user_id === user.id
// Check if user is a collaborator
if (!isOwner) {
const { data: collab } = await supabase
.from('project_collaborators')
.select('id')
.eq('project_id', projectId)
.eq('user_id', user.id)
.single()
if (!collab) {
return { success: false, error: 'Access denied' }
}
}
// Fetch collaborators with profile info
const { data: collaborators, error } = await supabase
.from('project_collaborators')
.select('id, user_id, role, invited_at, accepted_at, profiles(display_name, email)')
.eq('project_id', projectId)
if (error) {
return { success: false, error: error.message }
}
const result: Collaborator[] = (collaborators || []).map((c) => {
const profile = c.profiles as unknown as { display_name: string | null; email: string | null } | null
return {
id: c.id,
user_id: c.user_id,
role: c.role as 'owner' | 'editor' | 'viewer',
invited_at: c.invited_at,
accepted_at: c.accepted_at,
display_name: profile?.display_name || null,
email: profile?.email || null,
}
})
return { success: true, data: result }
}
export async function inviteCollaborator(
projectId: string,
email: string,
role: 'editor' | 'viewer'
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
if (!project) {
return { success: false, error: 'Only the project owner can invite collaborators' }
}
// Find the user by email in profiles
const { data: targetProfile } = await supabase
.from('profiles')
.select('id, email')
.eq('email', email)
.single()
if (!targetProfile) {
return { success: false, error: 'No user found with that email address' }
}
// Cannot invite yourself
if (targetProfile.id === user.id) {
return { success: false, error: 'You cannot invite yourself' }
}
// Check if already a collaborator
const { data: existing } = await supabase
.from('project_collaborators')
.select('id')
.eq('project_id', projectId)
.eq('user_id', targetProfile.id)
.single()
if (existing) {
return { success: false, error: 'This user is already a collaborator' }
}
// Insert collaborator
const { error: insertError } = await supabase
.from('project_collaborators')
.insert({
project_id: projectId,
user_id: targetProfile.id,
role,
invited_at: new Date().toISOString(),
accepted_at: new Date().toISOString(), // Auto-accept for now
})
if (insertError) {
return { success: false, error: insertError.message }
}
return { success: true }
}
export async function updateCollaboratorRole(
projectId: string,
collaboratorId: string,
role: 'editor' | 'viewer'
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
if (!project) {
return { success: false, error: 'Only the project owner can change roles' }
}
const { error } = await supabase
.from('project_collaborators')
.update({ role })
.eq('id', collaboratorId)
.eq('project_id', projectId)
if (error) {
return { success: false, error: error.message }
}
return { success: true }
}
export async function removeCollaborator(
projectId: string,
collaboratorId: string
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
if (!project) {
return { success: false, error: 'Only the project owner can remove collaborators' }
}
const { error } = await supabase
.from('project_collaborators')
.delete()
.eq('id', collaboratorId)
.eq('project_id', projectId)
if (error) {
return { success: false, error: error.message }
}
return { success: true }
}
+50 -36
View File
@@ -19,39 +19,52 @@ export default async function EditorPage({ params }: PageProps) {
return null return null
} }
const { data: project, error } = await supabase // Fetch user's display name for presence
const { data: profile } = await supabase
.from('profiles')
.select('display_name')
.eq('id', user.id)
.single()
const userDisplayName = profile?.display_name || user.email || 'Anonymous'
// Try to load as owner first
const { data: ownedProject } = await supabase
.from('projects') .from('projects')
.select('id, name, flowchart_data') .select('id, name, flowchart_data')
.eq('id', projectId) .eq('id', projectId)
.eq('user_id', user.id) .eq('user_id', user.id)
.single() .single()
if (error || !project) { let project = ownedProject
<<<<<<< HEAD let isOwner = true
notFound()
// If not the owner, check if the user is a collaborator
if (!project) {
const { data: collab } = await supabase
.from('project_collaborators')
.select('id, role')
.eq('project_id', projectId)
.eq('user_id', user.id)
.single()
if (collab) {
const { data: sharedProject } = await supabase
.from('projects')
.select('id, name, flowchart_data')
.eq('id', projectId)
.single()
project = sharedProject
isOwner = false
}
} }
const rawData = project.flowchart_data || {} if (!project) {
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 ( return (
<div className="flex h-screen flex-col"> <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"> <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"> <div className="flex items-center gap-4">
=======
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">
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
<Link <Link
href="/dashboard" href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50" className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
@@ -70,6 +83,7 @@ export default async function EditorPage({ params }: PageProps) {
/> />
</svg> </svg>
</Link> </Link>
</div>
</header> </header>
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800"> <div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-4 text-center"> <div className="flex flex-col items-center gap-4 text-center">
@@ -100,31 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
</Link> </Link>
</div> </div>
</div> </div>
<<<<<<< HEAD
</header>
<div className="flex-1">
<FlowchartEditor
projectId={project.id}
initialData={flowchartData}
needsMigration={needsMigration}
/>
=======
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div> </div>
) )
} }
const flowchartData = (project.flowchart_data || { const rawData = project.flowchart_data || {}
nodes: [], const flowchartData: FlowchartData = {
edges: [], nodes: rawData.nodes || [],
}) as FlowchartData edges: rawData.edges || [],
characters: rawData.characters || [],
variables: rawData.variables || [],
}
// Migration flag: if the raw data doesn't have characters/variables arrays,
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return ( return (
<FlowchartEditor <FlowchartEditor
projectId={project.id} projectId={project.id}
projectName={project.name} projectName={project.name}
userId={user.id}
userDisplayName={userDisplayName}
isOwner={isOwner}
initialData={flowchartData} initialData={flowchartData}
needsMigration={needsMigration}
/> />
) )
} }
+13
View File
@@ -24,3 +24,16 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
/* Export validation warning highlighting for React Flow nodes */
.react-flow__node.export-warning-node {
outline: 3px solid #f97316;
outline-offset: 2px;
border-radius: 6px;
animation: pulse-warning 1.5s ease-in-out infinite;
}
@keyframes pulse-warning {
0%, 100% { outline-color: #f97316; }
50% { outline-color: #fb923c; }
}
+17 -4
View File
@@ -8,8 +8,10 @@ interface ProjectCardProps {
id: string id: string
name: string name: string
updatedAt: string updatedAt: string
onDelete: (id: string) => void onDelete?: (id: string) => void
onRename: (id: string, newName: string) => void onRename?: (id: string, newName: string) => void
shared?: boolean
sharedRole?: string
} }
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
@@ -29,6 +31,8 @@ export default function ProjectCard({
updatedAt, updatedAt,
onDelete, onDelete,
onRename, onRename,
shared,
sharedRole,
}: ProjectCardProps) { }: ProjectCardProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
@@ -62,7 +66,7 @@ export default function ProjectCard({
setIsDeleting(false) setIsDeleting(false)
setShowDeleteDialog(false) setShowDeleteDialog(false)
onDelete(id) onDelete?.(id)
} }
const handleCancelDelete = () => { const handleCancelDelete = () => {
@@ -106,7 +110,7 @@ export default function ProjectCard({
setIsRenaming(false) setIsRenaming(false)
setShowRenameDialog(false) setShowRenameDialog(false)
onRename(id, newName.trim()) onRename?.(id, newName.trim())
} }
const handleCancelRename = () => { const handleCancelRename = () => {
@@ -122,6 +126,7 @@ export default function ProjectCard({
onClick={handleCardClick} onClick={handleCardClick}
className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600" className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600"
> >
{!shared && onRename && onDelete && (
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button <button
onClick={handleRenameClick} onClick={handleRenameClick}
@@ -162,6 +167,14 @@ export default function ProjectCard({
</svg> </svg>
</button> </button>
</div> </div>
)}
{shared && (
<div className="absolute right-3 top-3">
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{sharedRole === 'editor' ? 'Editor' : 'Viewer'}
</span>
</div>
)}
<h2 className="pr-8 text-lg font-semibold text-zinc-900 group-hover:text-blue-600 dark:text-zinc-50 dark:group-hover:text-blue-400"> <h2 className="pr-8 text-lg font-semibold text-zinc-900 group-hover:text-blue-600 dark:text-zinc-50 dark:group-hover:text-blue-400">
{name} {name}
</h2> </h2>
@@ -0,0 +1,357 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import RevertConfirmDialog from './RevertConfirmDialog'
const PAGE_SIZE = 20
export type AuditEntry = {
id: string
project_id: string
user_id: string
action_type: string
entity_id: string
previous_state: Record<string, unknown> | null
new_state: Record<string, unknown> | null
created_at: string
user_display_name?: string
}
type ActivityHistorySidebarProps = {
projectId: string
onClose: () => void
onSelectEntity: (entityId: string, actionType: string) => void
onRevert: (entry: AuditEntry) => void
}
const ACTION_LABELS: Record<string, string> = {
node_add: 'Added node',
node_update: 'Updated node',
node_delete: 'Deleted node',
edge_add: 'Added edge',
edge_update: 'Updated edge',
edge_delete: 'Deleted edge',
}
const ACTION_ICONS: Record<string, string> = {
node_add: '+',
node_update: '~',
node_delete: '-',
edge_add: '+',
edge_update: '~',
edge_delete: '-',
}
const ACTION_COLORS: Record<string, string> = {
node_add: 'text-green-500',
node_update: 'text-blue-500',
node_delete: 'text-red-500',
edge_add: 'text-green-500',
edge_update: 'text-blue-500',
edge_delete: 'text-red-500',
}
// Same hash logic as PresenceAvatars and FlowchartEditor
const PRESENCE_COLORS = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
function getUserColor(userId: string): string {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
}
return PRESENCE_COLORS[Math.abs(hash) % PRESENCE_COLORS.length]
}
function getEntityDescription(entry: AuditEntry): string {
const state = entry.new_state || entry.previous_state
if (!state) return entry.entity_id.slice(0, 8)
if (entry.action_type.startsWith('node_')) {
const type = (state as Record<string, unknown>).type as string | undefined
const data = (state as Record<string, unknown>).data as Record<string, unknown> | undefined
if (type === 'dialogue' && data) {
const speaker = (data.speaker as string) || (data.characterId as string) || ''
return speaker ? `Dialogue (${speaker})` : 'Dialogue node'
}
if (type === 'choice' && data) {
const question = (data.question as string) || ''
return question ? `Choice: "${question.slice(0, 20)}${question.length > 20 ? '…' : ''}"` : 'Choice node'
}
if (type === 'variable' && data) {
const name = (data.variableName as string) || ''
return name ? `Variable: ${name}` : 'Variable node'
}
return `${type || 'Unknown'} node`
}
// Edge entries
return 'Connection'
}
function formatTime(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function getTimePeriod(dateStr: string): 'Today' | 'Yesterday' | 'Earlier' {
const date = new Date(dateStr)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (date >= today) return 'Today'
if (date >= yesterday) return 'Yesterday'
return 'Earlier'
}
type GroupedEntries = {
period: 'Today' | 'Yesterday' | 'Earlier'
entries: AuditEntry[]
}
function groupByPeriod(entries: AuditEntry[]): GroupedEntries[] {
const groups: Record<string, AuditEntry[]> = {}
const order: ('Today' | 'Yesterday' | 'Earlier')[] = ['Today', 'Yesterday', 'Earlier']
for (const entry of entries) {
const period = getTimePeriod(entry.created_at)
if (!groups[period]) groups[period] = []
groups[period].push(entry)
}
return order
.filter((p) => groups[p] && groups[p].length > 0)
.map((p) => ({ period: p, entries: groups[p] }))
}
export default function ActivityHistorySidebar({
projectId,
onClose,
onSelectEntity,
onRevert,
}: ActivityHistorySidebarProps) {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState<string | null>(null)
const [revertEntry, setRevertEntry] = useState<AuditEntry | null>(null)
const mountedRef = useRef(true)
const fetchEntriesRaw = useCallback(async (offset: number): Promise<{ entries: AuditEntry[]; hasMore: boolean; error?: string }> => {
const supabase = createClient()
const { data, error: fetchError } = await supabase
.from('audit_trail')
.select('id, project_id, user_id, action_type, entity_id, previous_state, new_state, created_at')
.eq('project_id', projectId)
.order('created_at', { ascending: false })
.range(offset, offset + PAGE_SIZE - 1)
if (fetchError) {
return { entries: [], hasMore: false, error: fetchError.message }
}
const moreAvailable = !!data && data.length >= PAGE_SIZE
// Fetch user display names for the entries
if (data && data.length > 0) {
const userIds = [...new Set(data.map((e) => e.user_id))]
const { data: profiles } = await supabase
.from('profiles')
.select('id, display_name')
.in('id', userIds)
const nameMap = new Map<string, string>()
if (profiles) {
for (const p of profiles) {
nameMap.set(p.id, p.display_name || 'Unknown')
}
}
return {
entries: data.map((e) => ({
...e,
user_display_name: nameMap.get(e.user_id) || 'Unknown',
})),
hasMore: moreAvailable,
}
}
return { entries: data || [], hasMore: moreAvailable }
}, [projectId])
useEffect(() => {
mountedRef.current = true
const controller = new AbortController()
fetchEntriesRaw(0).then((result) => {
if (!controller.signal.aborted && mountedRef.current) {
if (result.error) {
setError(result.error)
} else {
setEntries(result.entries)
setHasMore(result.hasMore)
}
setLoading(false)
}
})
return () => {
controller.abort()
mountedRef.current = false
}
}, [fetchEntriesRaw])
const handleLoadMore = async () => {
setLoadingMore(true)
const result = await fetchEntriesRaw(entries.length)
if (result.error) {
setError(result.error)
} else {
setEntries((prev) => [...prev, ...result.entries])
setHasMore(result.hasMore)
}
setLoadingMore(false)
}
const grouped = groupByPeriod(entries)
return (
<div className="absolute right-0 top-0 z-40 flex h-full w-80 flex-col border-l border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Activity History
</h2>
<button
onClick={onClose}
className="rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
title="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center py-8">
<svg className="h-5 w-5 animate-spin text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
)}
{error && (
<div className="px-4 py-3 text-sm text-red-500 dark:text-red-400">
Failed to load history: {error}
</div>
)}
{!loading && !error && entries.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
No activity recorded yet
</div>
)}
{!loading && grouped.map((group) => (
<div key={group.period}>
<div className="sticky top-0 bg-zinc-50 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-zinc-500 dark:bg-zinc-850 dark:text-zinc-400" style={{ backgroundColor: 'inherit' }}>
<div className="bg-zinc-50 dark:bg-zinc-800">
{group.period}
</div>
</div>
<div className="divide-y divide-zinc-100 dark:divide-zinc-700/50">
{group.entries.map((entry) => {
const userColor = getUserColor(entry.user_id)
const isDeleted = entry.action_type.endsWith('_delete')
return (
<div
key={entry.id}
className={`group w-full px-4 py-2.5 text-left transition-colors ${
isDeleted
? 'opacity-60'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
}`}
>
<div className="flex items-start gap-2.5">
<div
className="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: userColor }}
title={entry.user_display_name}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className={`text-xs font-bold ${ACTION_COLORS[entry.action_type] || 'text-zinc-500'}`}>
{ACTION_ICONS[entry.action_type] || '?'}
</span>
<button
onClick={() => !isDeleted && onSelectEntity(entry.entity_id, entry.action_type)}
disabled={isDeleted}
className={`truncate text-xs font-medium text-zinc-800 dark:text-zinc-200 ${!isDeleted ? 'hover:underline' : ''}`}
>
{ACTION_LABELS[entry.action_type] || entry.action_type}
</button>
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">
{getEntityDescription(entry)}
</div>
<div className="mt-0.5 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] text-zinc-400 dark:text-zinc-500">
<span>{entry.user_display_name}</span>
<span>·</span>
<span>{formatTime(entry.created_at)}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
setRevertEntry(entry)
}}
className="hidden rounded px-1.5 py-0.5 text-[10px] font-medium text-amber-600 hover:bg-amber-50 group-hover:inline-block dark:text-amber-400 dark:hover:bg-amber-900/30"
title="Revert this change"
>
Revert
</button>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
))}
{!loading && hasMore && entries.length > 0 && (
<div className="px-4 py-3">
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="w-full rounded border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
{loadingMore ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</div>
{revertEntry && (
<RevertConfirmDialog
actionType={revertEntry.action_type}
entityDescription={getEntityDescription(revertEntry)}
previousState={revertEntry.previous_state}
newState={revertEntry.new_state}
onConfirm={() => {
onRevert(revertEntry)
setRevertEntry(null)
}}
onCancel={() => setRevertEntry(null)}
/>
)}
</div>
)
}
@@ -0,0 +1,43 @@
'use client'
import { useEffect } from 'react'
export type CollaborationNotification = {
id: string
displayName: string
type: 'join' | 'leave'
color: string
}
type CollaborationToastProps = {
notification: CollaborationNotification
onDismiss: (id: string) => void
}
export default function CollaborationToast({ notification, onDismiss }: CollaborationToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(notification.id)
}, 3000)
return () => clearTimeout(timer)
}, [notification.id, onDismiss])
const message = notification.type === 'join'
? `${notification.displayName} joined`
: `${notification.displayName} left`
return (
<div className="animate-in fade-in slide-in-from-bottom-2">
<div
className="flex items-center gap-2 rounded-lg bg-zinc-800 px-4 py-2.5 text-sm font-medium text-white shadow-lg dark:bg-zinc-700"
>
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: notification.color }}
/>
<span>{message}</span>
</div>
</div>
)
}
-160
View File
@@ -1,18 +1,13 @@
'use client' 'use client'
<<<<<<< HEAD
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import Combobox from '@/components/editor/Combobox' import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox' import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext' import { useEditorContext } from '@/components/editor/EditorContext'
=======
import { useState, useCallback, useEffect } from 'react'
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
import type { Condition } from '@/types/flowchart' import type { Condition } from '@/types/flowchart'
type ConditionEditorProps = { type ConditionEditorProps = {
edgeId: string edgeId: string
<<<<<<< HEAD
condition: Condition | undefined condition: Condition | undefined
onChange: (edgeId: string, condition: Condition | undefined) => void onChange: (edgeId: string, condition: Condition | undefined) => void
onClose: () => void onClose: () => void
@@ -220,133 +215,10 @@ export default function ConditionEditor({
{hasInvalidReference && ( {hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400"> <div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found Variable not found
=======
condition?: Condition
onSave: (edgeId: string, condition: Condition) => void
onRemove: (edgeId: string) => void
onCancel: () => void
}
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
export default function ConditionEditor({
edgeId,
condition,
onSave,
onRemove,
onCancel,
}: ConditionEditorProps) {
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
const [value, setValue] = useState(condition?.value ?? 0)
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
const handleSave = useCallback(() => {
if (!variableName.trim()) return
onSave(edgeId, {
variableName: variableName.trim(),
operator,
value,
})
}, [edgeId, variableName, operator, value, onSave])
const handleRemove = useCallback(() => {
onRemove(edgeId)
}, [edgeId, onRemove])
const hasExistingCondition = !!condition
return (
<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="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"
/>
</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>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div> </div>
)} )}
</div> </div>
<<<<<<< HEAD
{/* Inline add form */} {/* Inline add form */}
{showAddForm && ( {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-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
@@ -443,38 +315,6 @@ export default function ConditionEditor({
> >
Done Done
</button> </button>
=======
{/* 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"
>
Remove
</button>
)}
</div>
<div className="flex gap-2">
<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"
>
Cancel
</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>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div> </div>
</div> </div>
</div> </div>
+9
View File
@@ -2,12 +2,18 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import type { Character, Variable } from '@/types/flowchart' import type { Character, Variable } from '@/types/flowchart'
import type { NodeLock } from '@/lib/collaboration/realtime'
export type NodeLockInfo = NodeLock & { color: string }
type EditorContextValue = { type EditorContextValue = {
characters: Character[] characters: Character[]
onAddCharacter: (name: string, color: string) => string // returns new character id onAddCharacter: (name: string, color: string) => string // returns new character id
variables: Variable[] variables: Variable[]
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
nodeLocks: Map<string, NodeLockInfo> // nodeId -> lock info
onNodeFocus: (nodeId: string) => void
onNodeBlur: () => void
} }
const EditorContext = createContext<EditorContextValue>({ const EditorContext = createContext<EditorContextValue>({
@@ -15,6 +21,9 @@ const EditorContext = createContext<EditorContextValue>({
onAddCharacter: () => '', onAddCharacter: () => '',
variables: [], variables: [],
onAddVariable: () => '', onAddVariable: () => '',
nodeLocks: new Map(),
onNodeFocus: () => {},
onNodeBlur: () => {},
}) })
export const EditorProvider = EditorContext.Provider export const EditorProvider = EditorContext.Provider
+70 -13
View File
@@ -4,8 +4,8 @@ export type ValidationIssue = {
nodeId: string nodeId: string
nodeType: 'dialogue' | 'choice' | 'variable' | 'edge' nodeType: 'dialogue' | 'choice' | 'variable' | 'edge'
contentSnippet: string contentSnippet: string
undefinedReference: string
referenceType: 'character' | 'variable' referenceType: 'character' | 'variable'
referenceId: string
} }
type ExportValidationModalProps = { type ExportValidationModalProps = {
@@ -19,40 +19,97 @@ export default function ExportValidationModal({
onExportAnyway, onExportAnyway,
onCancel, onCancel,
}: ExportValidationModalProps) { }: ExportValidationModalProps) {
const characterIssues = issues.filter((i) => i.referenceType === 'character')
const variableIssues = issues.filter((i) => i.referenceType === 'variable')
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800"> <div className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800">
<div className="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700"> <div className="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<div className="flex items-center gap-2">
<svg
className="h-5 w-5 text-orange-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"> <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Undefined References Found Export Validation Issues
</h2> </h2>
</div>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400"> <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
The following nodes reference characters or variables that are no longer defined. {issues.length} undefined reference{issues.length !== 1 ? 's' : ''} found. These nodes/edges reference characters or variables that no longer exist.
</p> </p>
</div> </div>
<div className="max-h-[50vh] overflow-y-auto px-6 py-4"> <div className="max-h-[50vh] overflow-y-auto px-6 py-4">
<ul className="space-y-3"> {characterIssues.length > 0 && (
{issues.map((issue, index) => ( <div className="mb-4">
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Undefined Characters
</h3>
<ul className="space-y-2">
{characterIssues.map((issue, idx) => (
<li <li
key={`${issue.nodeId}-${issue.referenceId}-${index}`} key={`char-${idx}`}
className="rounded-md border border-orange-200 bg-orange-50 px-3 py-2 dark:border-orange-800 dark:bg-orange-950" className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
> >
<div className="flex items-center gap-2"> <div className="flex items-start justify-between gap-2">
<span className="inline-flex items-center rounded bg-zinc-200 px-1.5 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300"> <div className="min-w-0">
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{issue.nodeType} {issue.nodeType}
</span> </span>
<span className="inline-flex items-center rounded bg-orange-200 px-1.5 py-0.5 text-xs font-medium text-orange-800 dark:bg-orange-800 dark:text-orange-200">
undefined {issue.referenceType}
</span>
</div>
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300"> <p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
{issue.contentSnippet} {issue.contentSnippet}
</p> </p>
</div>
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
ID: {issue.undefinedReference.slice(0, 8)}...
</span>
</div>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
)}
{variableIssues.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
Undefined Variables
</h3>
<ul className="space-y-2">
{variableIssues.map((issue, idx) => (
<li
key={`var-${idx}`}
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{issue.nodeType}
</span>
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
{issue.contentSnippet}
</p>
</div>
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
ID: {issue.undefinedReference.slice(0, 8)}...
</span>
</div>
</li>
))}
</ul>
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700"> <div className="flex justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
<button <button
@@ -0,0 +1,24 @@
'use client'
import type { NodeLock } from '@/lib/collaboration/realtime'
type NodeLockIndicatorProps = {
lock: NodeLock
color: string
}
export default function NodeLockIndicator({ lock, color }: NodeLockIndicatorProps) {
return (
<div
className="pointer-events-none absolute inset-0 z-10 rounded-lg border-2"
style={{ borderColor: color }}
>
<div
className="absolute -top-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white"
style={{ backgroundColor: color }}
>
{lock.displayName}
</div>
</div>
)
}
@@ -1,6 +1,5 @@
'use client' 'use client'
<<<<<<< HEAD
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import Combobox from '@/components/editor/Combobox' import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox' import type { ComboboxItem } from '@/components/editor/Combobox'
@@ -209,143 +208,10 @@ export default function OptionConditionEditor({
{hasInvalidReference && ( {hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400"> <div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found Variable not found
=======
import { useState, useCallback, useEffect } from 'react'
import type { Condition } from '@/types/flowchart'
type OptionConditionEditorProps = {
optionId: string
optionLabel: string
condition?: Condition
onSave: (optionId: string, condition: Condition) => void
onRemove: (optionId: string) => void
onCancel: () => void
}
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
export default function OptionConditionEditor({
optionId,
optionLabel,
condition,
onSave,
onRemove,
onCancel,
}: OptionConditionEditorProps) {
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
const [value, setValue] = useState(condition?.value ?? 0)
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
const handleSave = useCallback(() => {
if (!variableName.trim()) return
onSave(optionId, {
variableName: variableName.trim(),
operator,
value,
})
}, [optionId, variableName, operator, value, onSave])
const handleRemove = useCallback(() => {
onRemove(optionId)
}, [optionId, onRemove])
const hasExistingCondition = !!condition
return (
<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="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"
/>
</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>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div> </div>
)} )}
</div> </div>
<<<<<<< HEAD
{/* Inline add form */} {/* Inline add form */}
{showAddForm && ( {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-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
@@ -442,38 +308,6 @@ export default function OptionConditionEditor({
> >
Done Done
</button> </button>
=======
{/* 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"
>
Remove
</button>
)}
</div>
<div className="flex gap-2">
<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"
>
Cancel
</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>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div> </div>
</div> </div>
</div> </div>
+65
View File
@@ -0,0 +1,65 @@
'use client'
import type { PresenceUser } from '@/lib/collaboration/realtime'
type PresenceAvatarsProps = {
users: PresenceUser[]
}
const MAX_VISIBLE = 5
// Generate a consistent color from a user ID hash
function getUserColor(userId: string): string {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
}
const colors = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
return colors[Math.abs(hash) % colors.length]
}
// Get initials from a display name (first letter of first two words)
function getInitials(displayName: string): string {
const parts = displayName.trim().split(/\s+/)
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase()
}
return displayName.slice(0, 2).toUpperCase()
}
export default function PresenceAvatars({ users }: PresenceAvatarsProps) {
if (users.length === 0) return null
const visibleUsers = users.slice(0, MAX_VISIBLE)
const overflow = users.length - MAX_VISIBLE
return (
<div className="flex items-center -space-x-2">
{visibleUsers.map((user) => {
const color = getUserColor(user.userId)
return (
<div
key={user.userId}
title={user.displayName}
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-semibold text-white dark:border-zinc-800"
style={{ backgroundColor: color }}
>
{getInitials(user.displayName)}
</div>
)
})}
{overflow > 0 && (
<div
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-zinc-400 text-[10px] font-semibold text-white dark:border-zinc-800 dark:bg-zinc-500"
title={`${overflow} more collaborator${overflow > 1 ? 's' : ''}`}
>
+{overflow}
</div>
)}
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
'use client'
import { useEffect, useState } from 'react'
import { useViewport } from 'reactflow'
import type { RemoteCursor } from '@/lib/collaboration/realtime'
type RemoteCursorsProps = {
cursors: RemoteCursor[]
}
const FADE_TIMEOUT_MS = 5000
// Generate a consistent color from a user ID hash (same algorithm as PresenceAvatars)
function getUserColor(userId: string): string {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
}
const colors = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
return colors[Math.abs(hash) % colors.length]
}
export default function RemoteCursors({ cursors }: RemoteCursorsProps) {
const viewport = useViewport()
const [now, setNow] = useState(() => Date.now())
// Tick every second to update fade states
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [])
return (
<div className="pointer-events-none absolute inset-0 z-40 overflow-hidden">
{cursors.map((cursor) => {
const timeSinceUpdate = now - cursor.lastUpdated
const isFaded = timeSinceUpdate > FADE_TIMEOUT_MS
if (isFaded) return null
const opacity = timeSinceUpdate > FADE_TIMEOUT_MS - 1000
? Math.max(0, 1 - (timeSinceUpdate - (FADE_TIMEOUT_MS - 1000)) / 1000)
: 1
// Convert flow coordinates to screen coordinates
const screenX = cursor.position.x * viewport.zoom + viewport.x
const screenY = cursor.position.y * viewport.zoom + viewport.y
const color = getUserColor(cursor.userId)
return (
<div
key={cursor.userId}
className="absolute left-0 top-0"
style={{
transform: `translate(${screenX}px, ${screenY}px)`,
opacity,
transition: 'transform 80ms linear, opacity 300ms ease',
}}
>
{/* Cursor arrow SVG */}
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
>
<path
d="M0 0L16 12L8 12L6 20L0 0Z"
fill={color}
/>
<path
d="M0 0L16 12L8 12L6 20L0 0Z"
stroke="white"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
{/* User name label */}
<div
className="absolute left-4 top-4 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm"
style={{ backgroundColor: color }}
>
{cursor.displayName}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,162 @@
'use client'
type RevertConfirmDialogProps = {
actionType: string
entityDescription: string
previousState: Record<string, unknown> | null
newState: Record<string, unknown> | null
onConfirm: () => void
onCancel: () => void
}
const ACTION_LABELS: Record<string, string> = {
node_add: 'node addition',
node_update: 'node update',
node_delete: 'node deletion',
edge_add: 'edge addition',
edge_update: 'edge update',
edge_delete: 'edge deletion',
}
function formatStatePreview(state: Record<string, unknown> | null): string {
if (!state) return '(none)'
const type = state.type as string | undefined
const data = state.data as Record<string, unknown> | undefined
if (type === 'dialogue' && data) {
const speaker = (data.speaker as string) || '(no speaker)'
const text = (data.text as string) || '(no text)'
return `Dialogue: ${speaker}\n"${text.slice(0, 80)}${text.length > 80 ? '…' : ''}"`
}
if (type === 'choice' && data) {
const prompt = (data.prompt as string) || '(no prompt)'
const options = (data.options as { label: string }[]) || []
const optionLabels = options.map((o) => o.label || '(empty)').join(', ')
return `Choice: ${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}\nOptions: ${optionLabels}`
}
if (type === 'variable' && data) {
const name = (data.variableName as string) || '(unnamed)'
const op = (data.operation as string) || 'set'
const val = data.value ?? 0
return `Variable: ${name} ${op} ${val}`
}
// Edge state
if (state.source && state.target) {
const condition = (state.data as Record<string, unknown> | undefined)?.condition as Record<string, unknown> | undefined
if (condition) {
return `Edge: ${state.source} → ${state.target}\nCondition: ${condition.variableName} ${condition.operator} ${condition.value}`
}
return `Edge: ${state.source} → ${state.target}`
}
return JSON.stringify(state, null, 2).slice(0, 200)
}
function getRevertDescription(actionType: string): string {
switch (actionType) {
case 'node_add':
return 'This will delete the node that was added.'
case 'node_update':
return 'This will restore the node to its previous state.'
case 'node_delete':
return 'This will re-create the deleted node.'
case 'edge_add':
return 'This will delete the edge that was added.'
case 'edge_update':
return 'This will restore the edge to its previous state.'
case 'edge_delete':
return 'This will re-create the deleted edge.'
default:
return 'This will undo the change.'
}
}
export default function RevertConfirmDialog({
actionType,
entityDescription,
previousState,
newState,
onConfirm,
onCancel,
}: RevertConfirmDialogProps) {
const label = ACTION_LABELS[actionType] || actionType
const description = getRevertDescription(actionType)
// For revert, the "before" is the current state (newState) and "after" is what we're restoring to (previousState)
const isAddition = actionType.endsWith('_add')
const isDeletion = actionType.endsWith('_delete')
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Revert {label}
</h3>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{entityDescription}
</p>
</div>
<div className="px-5 py-4">
<p className="mb-3 text-sm text-zinc-700 dark:text-zinc-300">
{description}
</p>
<div className="grid grid-cols-2 gap-3">
{!isAddition && (
<div>
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
Current State
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
{formatStatePreview(newState)}
</pre>
</div>
)}
{!isDeletion && (
<div>
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
{isAddition ? 'Will be removed' : 'Restored State'}
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
{isAddition ? formatStatePreview(newState) : formatStatePreview(previousState)}
</pre>
</div>
)}
{isDeletion && (
<div className="col-span-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
Will be restored
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
{formatStatePreview(previousState)}
</pre>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200 px-5 py-3 dark:border-zinc-700">
<button
onClick={onCancel}
className="rounded px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Cancel
</button>
<button
onClick={onConfirm}
className="rounded bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700"
>
Revert
</button>
</div>
</div>
</div>
)
}
+224
View File
@@ -0,0 +1,224 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
getCollaborators,
inviteCollaborator,
updateCollaboratorRole,
removeCollaborator,
type Collaborator,
} from '@/app/editor/[projectId]/actions'
type ShareModalProps = {
projectId: string
isOwner: boolean
onClose: () => void
}
export default function ShareModal({ projectId, isOwner, onClose }: ShareModalProps) {
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [loading, setLoading] = useState(true)
const [email, setEmail] = useState('')
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
const [inviting, setInviting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const fetchCollaborators = useCallback(async () => {
const result = await getCollaborators(projectId)
if (result.success && result.data) {
setCollaborators(result.data)
}
setLoading(false)
}, [projectId])
useEffect(() => {
fetchCollaborators()
}, [fetchCollaborators])
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setInviting(true)
setError(null)
setSuccessMessage(null)
const result = await inviteCollaborator(projectId, email.trim(), role)
if (result.success) {
setEmail('')
setSuccessMessage('Collaborator invited successfully')
fetchCollaborators()
} else {
setError(result.error || 'Failed to invite collaborator')
}
setInviting(false)
}
const handleRoleChange = async (collaboratorId: string, newRole: 'editor' | 'viewer') => {
setError(null)
const result = await updateCollaboratorRole(projectId, collaboratorId, newRole)
if (result.success) {
setCollaborators((prev) =>
prev.map((c) => (c.id === collaboratorId ? { ...c, role: newRole } : c))
)
} else {
setError(result.error || 'Failed to update role')
}
}
const handleRemove = async (collaboratorId: string) => {
setError(null)
const result = await removeCollaborator(projectId, collaboratorId)
if (result.success) {
setCollaborators((prev) => prev.filter((c) => c.id !== collaboratorId))
} else {
setError(result.error || 'Failed to remove collaborator')
}
}
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 w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Share Project
</h2>
<button
onClick={onClose}
className="rounded p-1 text-zinc-400 hover:text-zinc-600 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>
{/* Invite Form - only for owners */}
{isOwner && (
<form onSubmit={handleInvite} className="mb-6">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Invite collaborator by email
</label>
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
disabled={inviting}
className="flex-1 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 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as 'editor' | 'viewer')}
disabled={inviting}
className="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 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50"
>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<button
type="submit"
disabled={inviting || !email.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-zinc-800"
>
{inviting ? 'Inviting...' : 'Invite'}
</button>
</div>
</form>
)}
{/* Messages */}
{error && (
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
{successMessage}
</div>
)}
{/* Collaborators List */}
<div>
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
Collaborators
</h3>
{loading ? (
<p className="text-sm text-zinc-500 dark:text-zinc-400">Loading...</p>
) : collaborators.length === 0 ? (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No collaborators yet. Invite someone to get started.
</p>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{collaborators.map((collab) => (
<div
key={collab.id}
className="flex items-center justify-between rounded-md border border-zinc-200 p-3 dark:border-zinc-700"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-900 truncate dark:text-zinc-50">
{collab.display_name || collab.email || 'Unknown user'}
</p>
{collab.display_name && collab.email && (
<p className="text-xs text-zinc-500 truncate dark:text-zinc-400">
{collab.email}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-3">
{isOwner ? (
<>
<select
value={collab.role}
onChange={(e) =>
handleRoleChange(collab.id, e.target.value as 'editor' | 'viewer')
}
className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs text-zinc-700 focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<button
onClick={() => handleRemove(collab.id)}
className="rounded p-1 text-zinc-400 hover:text-red-600 dark:hover:text-red-400"
title="Remove collaborator"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</>
) : (
<span className="rounded bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">
{collab.role}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Close button */}
<div className="mt-6 flex justify-end">
<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 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-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Close
</button>
</div>
</div>
</div>
)
}
+62 -8
View File
@@ -1,18 +1,37 @@
'use client' 'use client'
import Link from 'next/link'
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
import PresenceAvatars from './PresenceAvatars'
type ToolbarProps = { type ToolbarProps = {
onAddDialogue: () => void onAddDialogue: () => void
onAddChoice: () => void onAddChoice: () => void
onAddVariable: () => void onAddVariable: () => void
onSave: () => void onSave: () => void
isSaving?: boolean
onExport: () => void onExport: () => void
onExportRenpy: () => void onExportRenpy: () => void
onImport: () => void onImport: () => void
<<<<<<< HEAD
onProjectSettings: () => void onProjectSettings: () => void
======= onShare: () => void
isSaving?: boolean onHistory: () => void
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 connectionState?: ConnectionState
presenceUsers?: PresenceUser[]
}
const connectionLabel: Record<ConnectionState, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected',
reconnecting: 'Reconnecting…',
}
const connectionColor: Record<ConnectionState, string> = {
connecting: 'bg-yellow-400',
connected: 'bg-green-400',
disconnected: 'bg-red-400',
reconnecting: 'bg-yellow-400',
} }
export default function Toolbar({ export default function Toolbar({
@@ -20,18 +39,28 @@ export default function Toolbar({
onAddChoice, onAddChoice,
onAddVariable, onAddVariable,
onSave, onSave,
isSaving,
onExport, onExport,
onExportRenpy, onExportRenpy,
onImport, onImport,
<<<<<<< HEAD
onProjectSettings, onProjectSettings,
======= onShare,
isSaving = false, onHistory,
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 connectionState,
presenceUsers,
}: ToolbarProps) { }: ToolbarProps) {
return ( return (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800"> <div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link
href="/dashboard"
className="mr-2 flex items-center gap-1 text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
title="Back to Dashboard"
>
<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>
</Link>
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400"> <span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
Add Node: Add Node:
</span> </span>
@@ -56,12 +85,37 @@ export default function Toolbar({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{presenceUsers && presenceUsers.length > 0 && (
<div className="mr-2">
<PresenceAvatars users={presenceUsers} />
</div>
)}
{connectionState && (
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[connectionState]}>
<span className={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{connectionLabel[connectionState]}
</span>
</div>
)}
<button <button
onClick={onProjectSettings} 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" 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 Project Settings
</button> </button>
<button
onClick={onShare}
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"
>
Share
</button>
<button
onClick={onHistory}
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"
>
History
</button>
<button <button
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={isSaving}
+123 -245
View File
@@ -1,19 +1,12 @@
'use client' 'use client'
<<<<<<< HEAD
import { useCallback, useMemo, useState, ChangeEvent } from 'react' import { useCallback, useMemo, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { useEditorContext } from '@/components/editor/EditorContext' import { useEditorContext } from '@/components/editor/EditorContext'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor' import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
import type { Condition } from '@/types/flowchart' import type { Condition } from '@/types/flowchart'
=======
import { useCallback, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
import type { Condition } from '@/types/flowchart'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
type ChoiceOption = { type ChoiceOption = {
id: string id: string
@@ -31,12 +24,14 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) { export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow() const { setNodes } = useReactFlow()
<<<<<<< HEAD const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
const { variables } = useEditorContext()
=======
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null) const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
// --- Handlers de Atualização ---
const updatePrompt = useCallback( const updatePrompt = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { (e: ChangeEvent<HTMLInputElement>) => {
setNodes((nodes) => setNodes((nodes) =>
@@ -71,8 +66,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[id, setNodes] [id, setNodes]
) )
const updateOptionCondition = useCallback( const handleSaveCondition = useCallback((optionId: string, condition: Condition) => {
(optionId: string, condition: Condition | undefined) => {
setNodes((nodes) => setNodes((nodes) =>
nodes.map((node) => nodes.map((node) =>
node.id === id node.id === id
@@ -88,9 +82,27 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
: node : node
) )
) )
setEditingConditionOptionId(null)
}, [id, setNodes])
const handleRemoveCondition = useCallback((optionId: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, condition: undefined } : opt
),
}, },
[id, setNodes] }
: node
) )
)
setEditingConditionOptionId(null)
}, [id, setNodes])
const addOption = useCallback(() => { const addOption = useCallback(() => {
if (data.options.length >= MAX_OPTIONS) return if (data.options.length >= MAX_OPTIONS) return
@@ -101,10 +113,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
...node, ...node,
data: { data: {
...node.data, ...node.data,
options: [ options: [...node.data.options, { id: nanoid(), label: '' }],
...node.data.options,
{ id: nanoid(), label: '' },
],
}, },
} }
: node : node
@@ -122,9 +131,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
...node, ...node,
data: { data: {
...node.data, ...node.data,
options: node.data.options.filter( options: node.data.options.filter((opt: ChoiceOption) => opt.id !== optionId),
(opt: ChoiceOption) => opt.id !== optionId
),
}, },
} }
: node : node
@@ -134,7 +141,8 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[id, data.options.length, setNodes] [id, data.options.length, setNodes]
) )
<<<<<<< HEAD // --- Auxiliares ---
const editingOption = useMemo(() => { const editingOption = useMemo(() => {
if (!editingConditionOptionId) return null if (!editingConditionOptionId) return null
return data.options.find((opt) => opt.id === editingConditionOptionId) || null return data.options.find((opt) => opt.id === editingConditionOptionId) || null
@@ -148,251 +156,121 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[variables] [variables]
) )
======= const handleFocus = useCallback(() => {
const handleSaveCondition = useCallback( onNodeFocus(id)
(optionId: string, condition: Condition) => { }, [id, onNodeFocus])
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, condition } : opt
),
},
}
: node
)
)
setEditingConditionOptionId(null)
},
[id, setNodes]
)
const handleRemoveCondition = useCallback(
(optionId: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) => {
if (opt.id !== optionId) return opt
const updated = { ...opt }
delete updated.condition
return updated
}),
},
}
: node
)
)
setEditingConditionOptionId(null)
},
[id, setNodes]
)
const editingOption = editingConditionOptionId
? data.options.find((opt) => opt.id === editingConditionOptionId)
: null
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
return ( return (
<> <div
<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"> className="relative min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900"
<Handle onFocus={handleFocus}
type="target" onBlur={onNodeBlur}
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>
<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"> {isLockedByOther && (
<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" /> <NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
</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> {isLockedByOther && (
))} <div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
</div> <span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
Being edited by {lockInfo.displayName}
<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>
<<<<<<< HEAD
{editingOption && (
<OptionConditionEditor
condition={editingOption.condition}
onChange={(condition) => updateOptionCondition(editingOption.id, condition)}
onClose={() => setEditingConditionOptionId(null)}
/>
)}
</>
=======
<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
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
}`}
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
>
{option.condition ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</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"
>
&times;
</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 && (
<div className="ml-1 mt-0.5 flex items-center gap-1">
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
</span> </span>
</div> </div>
)} )}
<Handle type="target" position={Position.Top} className="!bg-green-500" />
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase text-green-600">Choice Node</span>
</div>
<input
type="text"
value={data.prompt || ''}
onChange={updatePrompt}
placeholder="Dialogue prompt..."
className="mb-3 w-full rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
/>
<div className="space-y-3">
{data.options.map((option, index) => (
<div key={option.id} className="group relative">
<div className="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-zinc-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
/>
{/* Botão de Condição */}
<button
onClick={() => setEditingConditionOptionId(option.id)}
className={`p-1 rounded transition-colors ${
option.condition
? hasInvalidConditionReference(option)
? 'bg-red-100 text-red-600 dark:bg-red-900/30'
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30'
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<button
onClick={() => removeOption(option.id)}
className="text-zinc-400 hover:text-red-500"
>
<svg className="w-4 h-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>
{/* Visualização da Condição */}
{option.condition && (
<div className={`mt-1 text-[9px] font-mono px-1 rounded ${
hasInvalidConditionReference(option) ? 'text-red-500 bg-red-50' : 'text-amber-600 bg-amber-50'
}`}>
IF: {option.condition.variableName} {option.condition.operator} {option.condition.value}
{hasInvalidConditionReference(option) && " (Variable Missing!)"}
</div>
)}
<Handle
type="source"
position={Position.Bottom}
id={option.id}
style={{ left: `${((index + 1) / (data.options.length + 1)) * 100}%` }}
className="!bg-green-500"
/>
</div> </div>
))} ))}
</div> </div>
<button <button
type="button"
onClick={addOption} onClick={addOption}
disabled={data.options.length >= MAX_OPTIONS} 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" className="mt-3 w-full border border-dashed border-zinc-300 py-1 text-[10px] text-zinc-500 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
title="Add option"
> >
+ Add Option + Add Option
</button> </button>
{/* Modal de Edição de Condição */}
{editingOption && ( {editingOption && (
<OptionConditionEditor <OptionConditionEditor
optionId={editingOption.id}
optionLabel={editingOption.label}
condition={editingOption.condition} condition={editingOption.condition}
onSave={handleSaveCondition} onChange={(cond) => {
onRemove={handleRemoveCondition} if (cond) {
onCancel={() => setEditingConditionOptionId(null)} handleSaveCondition(editingOption.id, cond)
} else {
handleRemoveCondition(editingOption.id)
}
}}
onClose={() => setEditingConditionOptionId(null)}
/> />
)} )}
</div> </div>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
) )
} }
+22 -2
View File
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox' import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox' import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext' import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type DialogueNodeData = { type DialogueNodeData = {
speaker?: string speaker?: string
@@ -24,7 +25,10 @@ function randomColor(): string {
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) { export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
const { setNodes } = useReactFlow() const { setNodes } = useReactFlow()
const { characters, onAddCharacter } = useEditorContext() const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
@@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
[updateNodeData] [updateNodeData]
) )
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return ( return (
<div <div
className={`min-w-[200px] rounded-lg border-2 ${ className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference hasInvalidReference
? 'border-orange-500 dark:border-orange-400' ? 'border-orange-500 dark:border-orange-400'
: 'border-blue-500 dark:border-blue-400' : 'border-blue-500 dark:border-blue-400'
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`} } bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
onFocus={handleFocus}
onBlur={onNodeBlur}
> >
{isLockedByOther && (
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
)}
{isLockedByOther && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
Being edited by {lockInfo.displayName}
</span>
</div>
)}
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
+22 -2
View File
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox' import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox' import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext' import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type VariableNodeData = { type VariableNodeData = {
variableName: string variableName: string
@@ -15,7 +16,10 @@ type VariableNodeData = {
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) { export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
const { setNodes } = useReactFlow() const { setNodes } = useReactFlow()
const { variables, onAddVariable } = useEditorContext() const { variables, onAddVariable, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
@@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
// Filter operations based on selected variable type // Filter operations based on selected variable type
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric' const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return ( return (
<div <div
className={`min-w-[200px] rounded-lg border-2 ${ className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference hasInvalidReference
? 'border-orange-500 dark:border-orange-400' ? 'border-orange-500 dark:border-orange-400'
: '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`} } bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
onFocus={handleFocus}
onBlur={onNodeBlur}
> >
{isLockedByOther && (
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
)}
{isLockedByOther && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
Being edited by {lockInfo.displayName}
</span>
</div>
)}
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
+218
View File
@@ -0,0 +1,218 @@
import { createClient } from '@/lib/supabase/client'
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
export type AuditActionType =
| 'node_add'
| 'node_update'
| 'node_delete'
| 'edge_add'
| 'edge_update'
| 'edge_delete'
type PendingAuditEntry = {
actionType: AuditActionType
entityId: string
previousState: unknown | null
newState: unknown | null
timer: ReturnType<typeof setTimeout>
}
const DEBOUNCE_MS = 1000
export class AuditTrailRecorder {
private projectId: string
private userId: string
private previousNodes: Map<string, string> // node ID -> JSON string
private previousEdges: Map<string, string> // edge ID -> JSON string
private pending: Map<string, PendingAuditEntry> // entityId -> pending entry
private isDestroyed = false
constructor(projectId: string, userId: string) {
this.projectId = projectId
this.userId = userId
this.previousNodes = new Map()
this.previousEdges = new Map()
this.pending = new Map()
}
/** Initialize with the current state (no audit entries are created for initial state) */
initialize(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
this.previousNodes.clear()
this.previousEdges.clear()
nodes.forEach((node) => {
this.previousNodes.set(node.id, JSON.stringify(node))
})
edges.forEach((edge) => {
this.previousEdges.set(edge.id, JSON.stringify(edge))
})
}
/** Record changes by diffing current state against previous state */
recordNodeChanges(currentNodes: FlowchartNode[]): void {
if (this.isDestroyed) return
const currentMap = new Map<string, string>()
currentNodes.forEach((node) => {
currentMap.set(node.id, JSON.stringify(node))
})
// Detect additions and updates
for (const [id, serialized] of currentMap) {
const previous = this.previousNodes.get(id)
if (!previous) {
// Node was added
this.scheduleWrite(id, 'node_add', null, JSON.parse(serialized))
} else if (previous !== serialized) {
// Node was updated
this.scheduleWrite(id, 'node_update', JSON.parse(previous), JSON.parse(serialized))
}
}
// Detect deletions
for (const [id, serialized] of this.previousNodes) {
if (!currentMap.has(id)) {
this.scheduleWrite(id, 'node_delete', JSON.parse(serialized), null)
}
}
// Update previous state
this.previousNodes = currentMap
}
/** Record edge changes by diffing current state against previous state */
recordEdgeChanges(currentEdges: FlowchartEdge[]): void {
if (this.isDestroyed) return
const currentMap = new Map<string, string>()
currentEdges.forEach((edge) => {
currentMap.set(edge.id, JSON.stringify(edge))
})
// Detect additions and updates
for (const [id, serialized] of currentMap) {
const previous = this.previousEdges.get(id)
if (!previous) {
// Edge was added
this.scheduleWrite(id, 'edge_add', null, JSON.parse(serialized))
} else if (previous !== serialized) {
// Edge was updated
this.scheduleWrite(id, 'edge_update', JSON.parse(previous), JSON.parse(serialized))
}
}
// Detect deletions
for (const [id, serialized] of this.previousEdges) {
if (!currentMap.has(id)) {
this.scheduleWrite(id, 'edge_delete', JSON.parse(serialized), null)
}
}
// Update previous state
this.previousEdges = currentMap
}
/** Clean up resources */
destroy(): void {
this.isDestroyed = true
// Flush all pending writes immediately
for (const [entityId, entry] of this.pending) {
clearTimeout(entry.timer)
this.writeEntry(entry)
this.pending.delete(entityId)
}
}
private scheduleWrite(
entityId: string,
actionType: AuditActionType,
previousState: unknown | null,
newState: unknown | null
): void {
// If there's already a pending write for this entity, cancel it and update
const existing = this.pending.get(entityId)
if (existing) {
clearTimeout(existing.timer)
// For debounced updates, keep the original previousState but use latest newState
// For type changes (e.g., add then delete within 1s), use the new action type
const mergedPreviousState = existing.actionType === 'node_add' || existing.actionType === 'edge_add'
? null // If it was just added, there's no real previous state
: existing.previousState
const mergedActionType = this.mergeActionTypes(existing.actionType, actionType)
// If an entity was added and then deleted within the debounce window, skip entirely
if (mergedActionType === null) {
this.pending.delete(entityId)
return
}
const entry: PendingAuditEntry = {
actionType: mergedActionType,
entityId,
previousState: mergedPreviousState,
newState,
timer: setTimeout(() => {
this.pending.delete(entityId)
this.writeEntry(entry)
}, DEBOUNCE_MS),
}
this.pending.set(entityId, entry)
} else {
const entry: PendingAuditEntry = {
actionType,
entityId,
previousState,
newState,
timer: setTimeout(() => {
this.pending.delete(entityId)
this.writeEntry(entry)
}, DEBOUNCE_MS),
}
this.pending.set(entityId, entry)
}
}
/** Merge two sequential action types for the same entity */
private mergeActionTypes(
first: AuditActionType,
second: AuditActionType
): AuditActionType | null {
// add + delete = no-op (entity never really existed)
if (
(first === 'node_add' && second === 'node_delete') ||
(first === 'edge_add' && second === 'edge_delete')
) {
return null
}
// add + update = still an add (with the updated state)
if (
(first === 'node_add' && second === 'node_update') ||
(first === 'edge_add' && second === 'edge_update')
) {
return first
}
// For all other cases, use the second (latest) action type
return second
}
/** Fire-and-forget write to Supabase */
private writeEntry(entry: PendingAuditEntry): void {
if (this.isDestroyed) return
const supabase = createClient()
supabase
.from('audit_trail')
.insert({
project_id: this.projectId,
user_id: this.userId,
action_type: entry.actionType,
entity_id: entry.entityId,
previous_state: entry.previousState,
new_state: entry.newState,
})
.then(({ error }) => {
if (error) {
console.error('[AuditTrail] Failed to write audit entry:', error)
}
})
}
}
+241
View File
@@ -0,0 +1,241 @@
import * as Y from 'yjs'
import type { RealtimeChannel } from '@supabase/supabase-js'
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
const PERSIST_DEBOUNCE_MS = 30_000 // 30s - DB persist is for crash recovery only; CRDT handles real-time sync
const BROADCAST_EVENT = 'yjs-update'
export type CRDTCallbacks = {
onNodesChange: (nodes: FlowchartNode[]) => void
onEdgesChange: (edges: FlowchartEdge[]) => void
onPersist: (nodes: FlowchartNode[], edges: FlowchartEdge[]) => void
}
export class CRDTManager {
private doc: Y.Doc
private nodesMap: Y.Map<string> // node ID -> JSON string of FlowchartNode
private edgesMap: Y.Map<string> // edge ID -> JSON string of FlowchartEdge
private channel: RealtimeChannel | null = null
private callbacks: CRDTCallbacks
private persistTimer: ReturnType<typeof setTimeout> | null = null
private isApplyingRemote = false
private isSuppressed = false // suppress broadcast/persist during init or refresh
private isDestroyed = false
constructor(callbacks: CRDTCallbacks) {
this.doc = new Y.Doc()
this.nodesMap = this.doc.getMap('nodes')
this.edgesMap = this.doc.getMap('edges')
this.callbacks = callbacks
// Schedule persistence on local Yjs document changes
this.nodesMap.observe(() => {
if (this.isApplyingRemote || this.isSuppressed) return
this.schedulePersist()
})
this.edgesMap.observe(() => {
if (this.isApplyingRemote || this.isSuppressed) return
this.schedulePersist()
})
// Broadcast local updates to other clients
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin === 'remote' || this.isSuppressed) return
this.broadcastUpdate(update)
})
}
/** Initialize the Yjs document from database state */
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
this.isSuppressed = true
this.doc.transact(() => {
nodes.forEach((node) => {
this.nodesMap.set(node.id, JSON.stringify(node))
})
edges.forEach((edge) => {
this.edgesMap.set(edge.id, JSON.stringify(edge))
})
}, 'init')
this.isSuppressed = false
}
/** Connect to a Supabase Realtime channel for outbound broadcasts */
connectChannel(channel: RealtimeChannel): void {
this.channel = channel
// Broadcast full state so other clients merge any updates they missed
this.broadcastFullState()
}
/** Broadcast the full Yjs document state to sync all connected clients */
broadcastFullState(): void {
if (!this.channel || this.isDestroyed) return
const state = Y.encodeStateAsUpdate(this.doc)
this.channel.send({
type: 'broadcast',
event: BROADCAST_EVENT,
payload: { update: Array.from(state) },
})
}
/** Apply a remote CRDT update received via broadcast */
applyRemoteUpdate(updateData: number[]): void {
if (this.isDestroyed) return
const update = new Uint8Array(updateData)
this.isApplyingRemote = true
Y.applyUpdate(this.doc, update, 'remote')
this.isApplyingRemote = false
// Notify React state of remote changes
this.notifyNodesChange()
this.notifyEdgesChange()
// Note: we do NOT schedulePersist here. Only the originating client
// persists its own changes to avoid write races and stale data overwrites.
}
/** Replace CRDT state from a database refresh without broadcasting */
refreshFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
this.isSuppressed = true
this.doc.transact(() => {
// Sync nodes
const nodeIds = new Set(nodes.map((n) => n.id))
Array.from(this.nodesMap.keys()).forEach((id) => {
if (!nodeIds.has(id)) this.nodesMap.delete(id)
})
nodes.forEach((node) => {
const serialized = JSON.stringify(node)
if (this.nodesMap.get(node.id) !== serialized) {
this.nodesMap.set(node.id, serialized)
}
})
// Sync edges
const edgeIds = new Set(edges.map((e) => e.id))
Array.from(this.edgesMap.keys()).forEach((id) => {
if (!edgeIds.has(id)) this.edgesMap.delete(id)
})
edges.forEach((edge) => {
const serialized = JSON.stringify(edge)
if (this.edgesMap.get(edge.id) !== serialized) {
this.edgesMap.set(edge.id, serialized)
}
})
}, 'remote')
this.isSuppressed = false
}
/** Apply local node changes to the Yjs document */
updateNodes(nodes: FlowchartNode[]): void {
if (this.isApplyingRemote) return
this.doc.transact(() => {
const currentIds = new Set(nodes.map((n) => n.id))
// Remove nodes no longer present
const existingIds = Array.from(this.nodesMap.keys())
existingIds.forEach((id) => {
if (!currentIds.has(id)) {
this.nodesMap.delete(id)
}
})
// Add or update nodes
nodes.forEach((node) => {
const serialized = JSON.stringify(node)
const existing = this.nodesMap.get(node.id)
if (existing !== serialized) {
this.nodesMap.set(node.id, serialized)
}
})
}, 'local')
}
/** Apply local edge changes to the Yjs document */
updateEdges(edges: FlowchartEdge[]): void {
if (this.isApplyingRemote) return
this.doc.transact(() => {
const currentIds = new Set(edges.map((e) => e.id))
// Remove edges no longer present
const existingIds = Array.from(this.edgesMap.keys())
existingIds.forEach((id) => {
if (!currentIds.has(id)) {
this.edgesMap.delete(id)
}
})
// Add or update edges
edges.forEach((edge) => {
const serialized = JSON.stringify(edge)
const existing = this.edgesMap.get(edge.id)
if (existing !== serialized) {
this.edgesMap.set(edge.id, serialized)
}
})
}, 'local')
}
/** Get current nodes from the Yjs document */
getNodes(): FlowchartNode[] {
const nodes: FlowchartNode[] = []
this.nodesMap.forEach((value) => {
try {
nodes.push(JSON.parse(value) as FlowchartNode)
} catch {
// Skip malformed entries
}
})
return nodes
}
/** Get current edges from the Yjs document */
getEdges(): FlowchartEdge[] {
const edges: FlowchartEdge[] = []
this.edgesMap.forEach((value) => {
try {
edges.push(JSON.parse(value) as FlowchartEdge)
} catch {
// Skip malformed entries
}
})
return edges
}
/** Clean up resources */
destroy(): void {
this.isDestroyed = true
if (this.persistTimer) {
clearTimeout(this.persistTimer)
this.persistTimer = null
}
this.doc.destroy()
this.channel = null
}
private notifyNodesChange(): void {
this.callbacks.onNodesChange(this.getNodes())
}
private notifyEdgesChange(): void {
this.callbacks.onEdgesChange(this.getEdges())
}
private broadcastUpdate(update: Uint8Array): void {
if (!this.channel || this.isDestroyed) return
this.channel.send({
type: 'broadcast',
event: BROADCAST_EVENT,
payload: { update: Array.from(update) },
})
}
private schedulePersist(): void {
if (this.persistTimer) {
clearTimeout(this.persistTimer)
}
this.persistTimer = setTimeout(() => {
if (this.isDestroyed) return
this.callbacks.onPersist(this.getNodes(), this.getEdges())
}, PERSIST_DEBOUNCE_MS)
}
}
+491
View File
@@ -0,0 +1,491 @@
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
export type PresenceUser = {
userId: string
displayName: string
}
export type CursorPosition = {
x: number
y: number
}
export type RemoteCursor = {
userId: string
displayName: string
position: CursorPosition
lastUpdated: number
}
export type NodeLock = {
nodeId: string
userId: string
displayName: string
lockedAt: number
}
type RealtimeCallbacks = {
onConnectionStateChange: (state: ConnectionState) => void
onPresenceSync?: (users: PresenceUser[]) => void
onPresenceJoin?: (user: PresenceUser) => void
onPresenceLeave?: (user: PresenceUser) => void
onChannelSubscribed?: (channel: RealtimeChannel) => void
onCursorUpdate?: (cursor: RemoteCursor) => void
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
onCRDTUpdate?: (update: number[]) => void
onStateRefresh?: () => void
}
const HEARTBEAT_INTERVAL_MS = 30_000
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection
const STALE_THRESHOLD_MS = 3 * 60_000 // 3 minutes hidden before forcing fresh reconnect
export class RealtimeConnection {
private channel: RealtimeChannel | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private inactivityTimer: ReturnType<typeof setTimeout> | null = null
private connectionTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
private projectId: string
private userId: string
private displayName: string
private callbacks: RealtimeCallbacks
private isDestroyed = false
private isPaused = false
private lastActivityTime = Date.now()
private supabase = createClient()
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
this.projectId = projectId
this.userId = userId
this.displayName = displayName
this.callbacks = callbacks
}
async connect(): Promise<void> {
if (this.isDestroyed) return
this.isPaused = false
this.lastActivityTime = Date.now()
this.callbacks.onConnectionStateChange('connecting')
this.resetInactivityTimer()
this.clearConnectionTimer()
// Ensure the Supabase client has a valid auth session before connecting.
// On initial page load, the session may still be loading from cookies.
try {
const { data: { session } } = await this.supabase.auth.getSession()
if (!session && !this.isDestroyed) {
// No session yet - wait briefly and retry
this.scheduleReconnect()
return
}
} catch {
// Session check failed - proceed anyway, channel will handle auth errors
}
if (this.isDestroyed || this.isPaused) return
// Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry
this.connectionTimer = setTimeout(() => {
if (this.isDestroyed || this.isPaused) return
if (this.channel) {
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.scheduleReconnect()
}, CONNECTION_TIMEOUT_MS)
this.channel = this.supabase.channel(`project:${this.projectId}`, {
config: { presence: { key: this.userId } },
})
this.channel
.on('presence', { event: 'sync' }, () => {
if (this.channel) {
const state = this.channel.presenceState()
const users: PresenceUser[] = []
for (const [key, presences] of Object.entries(state)) {
if (key === this.userId) continue // Exclude own presence
const presence = presences[0] as { userId?: string; displayName?: string } | undefined
if (presence?.userId) {
users.push({
userId: presence.userId,
displayName: presence.displayName || 'Anonymous',
})
}
}
this.callbacks.onPresenceSync?.(users)
}
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
for (const presence of newPresences) {
const p = presence as { userId?: string; displayName?: string }
if (p.userId && p.userId !== this.userId) {
this.callbacks.onPresenceJoin?.({
userId: p.userId,
displayName: p.displayName || 'Anonymous',
})
}
}
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
for (const presence of leftPresences) {
const p = presence as { userId?: string; displayName?: string }
if (p.userId && p.userId !== this.userId) {
this.callbacks.onPresenceLeave?.({
userId: p.userId,
displayName: p.displayName || 'Anonymous',
})
}
}
})
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
if (payload.userId === this.userId) return
this.callbacks.onCursorUpdate?.({
userId: payload.userId,
displayName: payload.displayName,
position: { x: payload.x, y: payload.y },
lastUpdated: Date.now(),
})
})
.on('broadcast', { event: 'node-lock' }, ({ payload }) => {
if (payload.userId === this.userId) return
if (payload.nodeId) {
this.callbacks.onNodeLockUpdate?.({
nodeId: payload.nodeId,
userId: payload.userId,
displayName: payload.displayName,
lockedAt: payload.lockedAt,
}, payload.userId)
} else {
// null nodeId means unlock
this.callbacks.onNodeLockUpdate?.(null, payload.userId)
}
})
.on('broadcast', { event: 'yjs-update' }, ({ payload }) => {
if (payload?.update) {
this.callbacks.onCRDTUpdate?.(payload.update)
}
})
.on('broadcast', { event: 'state-refresh' }, () => {
this.callbacks.onStateRefresh?.()
})
.subscribe(async (status) => {
if (this.isDestroyed) return
this.clearConnectionTimer()
if (status === 'SUBSCRIBED') {
this.reconnectAttempts = 0
this.callbacks.onConnectionStateChange('connected')
this.startHeartbeat()
await this.createSession()
// Track presence with user info
await this.channel?.track({
userId: this.userId,
displayName: this.displayName,
})
// Notify that the channel is ready for CRDT sync
if (this.channel) {
this.callbacks.onChannelSubscribed?.(this.channel)
}
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else if (status === 'CLOSED') {
if (!this.isPaused) {
// Unexpected close - attempt to reconnect
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else {
this.callbacks.onConnectionStateChange('disconnected')
}
}
})
}
async disconnect(): Promise<void> {
this.isDestroyed = true
this.isPaused = false
this.stopHeartbeat()
this.clearReconnectTimer()
this.clearInactivityTimer()
this.clearConnectionTimer()
if (this.channel) {
await this.deleteSession()
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.callbacks.onConnectionStateChange('disconnected')
}
/**
* Pause the connection (e.g. on inactivity or tab hidden).
* Unlike disconnect(), this allows resuming later.
*/
async pause(): Promise<void> {
if (this.isDestroyed || this.isPaused) return
this.isPaused = true
this.stopHeartbeat()
this.clearReconnectTimer()
this.clearInactivityTimer()
this.clearConnectionTimer()
if (this.channel) {
await this.deleteSession()
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.callbacks.onConnectionStateChange('disconnected')
}
/**
* Resume the connection after it was paused.
* Re-establishes the channel and presence.
*/
async resume(): Promise<void> {
if (this.isDestroyed || !this.isPaused) return
this.isPaused = false
this.reconnectAttempts = 0
await this.connect()
}
/**
* Notify that user activity has occurred, resetting the inactivity timer.
* If the connection was paused due to inactivity, it will resume.
*/
notifyActivity(): void {
if (this.isDestroyed) return
this.lastActivityTime = Date.now()
this.resetInactivityTimer()
if (this.isPaused) {
this.resume()
}
}
/**
* Called when the tab becomes visible again. If the tab was hidden for a
* significant period, force a fresh reconnect to handle stale WebSockets.
*/
notifyVisibilityResumed(): void {
if (this.isDestroyed) return
const now = Date.now()
const hiddenDuration = now - this.lastActivityTime
this.lastActivityTime = now
this.resetInactivityTimer()
if (this.isPaused) {
this.resume()
} else if (hiddenDuration > STALE_THRESHOLD_MS && this.channel) {
this.forceReconnect()
}
}
/**
* Force a fresh reconnect by tearing down the current channel and reconnecting.
*/
private async forceReconnect(): Promise<void> {
if (this.isDestroyed || this.isPaused) return
this.stopHeartbeat()
this.clearReconnectTimer()
this.clearConnectionTimer()
if (this.channel) {
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.reconnectAttempts = 0
await this.connect()
}
getIsPaused(): boolean {
return this.isPaused
}
getChannel(): RealtimeChannel | null {
return this.channel
}
broadcastCursor(position: CursorPosition): void {
if (!this.channel) return
this.channel.send({
type: 'broadcast',
event: 'cursor',
payload: {
userId: this.userId,
displayName: this.displayName,
x: position.x,
y: position.y,
},
})
}
broadcastStateRefresh(): void {
if (!this.channel) return
this.channel.send({
type: 'broadcast',
event: 'state-refresh',
payload: {},
})
}
broadcastNodeLock(nodeId: string | null): void {
if (!this.channel) return
this.channel.send({
type: 'broadcast',
event: 'node-lock',
payload: {
userId: this.userId,
displayName: this.displayName,
nodeId,
lockedAt: Date.now(),
},
})
}
private async createSession(): Promise<void> {
try {
await this.supabase.from('collaboration_sessions').upsert(
{
project_id: this.projectId,
user_id: this.userId,
cursor_position: null,
selected_node_id: null,
connected_at: new Date().toISOString(),
last_heartbeat: new Date().toISOString(),
},
{ onConflict: 'project_id,user_id' }
)
} catch {
// Fire-and-forget: session creation failure shouldn't block editing
}
}
private async deleteSession(): Promise<void> {
try {
await this.supabase
.from('collaboration_sessions')
.delete()
.eq('project_id', this.projectId)
.eq('user_id', this.userId)
} catch {
// Fire-and-forget: cleanup failure is non-critical
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
let consecutiveFailures = 0
this.heartbeatTimer = setInterval(async () => {
if (this.isDestroyed || this.isPaused) {
this.stopHeartbeat()
return
}
// Check if the channel is still in a healthy state
if (this.channel) {
const state = (this.channel as unknown as { state?: string }).state
if (state && state !== 'joined' && state !== 'joining') {
// Channel is in an unhealthy state - trigger reconnect
this.callbacks.onConnectionStateChange('reconnecting')
this.supabase.removeChannel(this.channel)
this.channel = null
this.stopHeartbeat()
this.scheduleReconnect()
return
}
}
try {
await this.supabase
.from('collaboration_sessions')
.update({ last_heartbeat: new Date().toISOString() })
.eq('project_id', this.projectId)
.eq('user_id', this.userId)
consecutiveFailures = 0
} catch {
consecutiveFailures++
// If heartbeat fails 3 times in a row, the connection is likely dead
if (consecutiveFailures >= 3 && this.channel) {
this.callbacks.onConnectionStateChange('reconnecting')
this.supabase.removeChannel(this.channel)
this.channel = null
this.stopHeartbeat()
this.scheduleReconnect()
}
}
}, HEARTBEAT_INTERVAL_MS)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private scheduleReconnect(): void {
if (this.isDestroyed) return
this.clearReconnectTimer()
const delay = Math.min(
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts),
RECONNECT_MAX_DELAY_MS
)
this.reconnectAttempts++
this.reconnectTimer = setTimeout(async () => {
if (this.isDestroyed) return
this.callbacks.onConnectionStateChange('reconnecting')
if (this.channel) {
this.supabase.removeChannel(this.channel)
this.channel = null
}
await this.connect()
}, delay)
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
private resetInactivityTimer(): void {
this.clearInactivityTimer()
if (this.isDestroyed) return
this.inactivityTimer = setTimeout(() => {
if (!this.isDestroyed && !this.isPaused) {
this.pause()
}
}, INACTIVITY_TIMEOUT_MS)
}
private clearInactivityTimer(): void {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer)
this.inactivityTimer = null
}
}
private clearConnectionTimer(): void {
if (this.connectionTimer) {
clearTimeout(this.connectionTimer)
this.connectionTimer = null
}
}
}
-8
View File
@@ -4,7 +4,6 @@ export type Position = {
y: number; y: number;
}; };
<<<<<<< HEAD
// Character type: represents a defined character in the project // Character type: represents a defined character in the project
export type Character = { export type Character = {
id: string; id: string;
@@ -28,13 +27,6 @@ export type Condition = {
variableId?: string; variableId?: string;
operator: '>' | '<' | '==' | '>=' | '<=' | '!='; operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
value: number | string | boolean; value: number | string | boolean;
=======
// Condition type for conditional edges and choice options
export type Condition = {
variableName: string;
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
value: number;
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
}; };
// DialogueNode type: represents character speech/dialogue // DialogueNode type: represents character speech/dialogue
@@ -0,0 +1,214 @@
-- Migration: Add collaboration sessions and audit trail tables
-- Supports real-time multi-user editing with presence tracking and change history
-- =============================================================================
-- PROJECT COLLABORATORS TABLE
-- =============================================================================
-- Tracks which users have access to a project and their role
CREATE TABLE IF NOT EXISTS project_collaborators (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
invited_at timestamptz DEFAULT now(),
accepted_at timestamptz,
UNIQUE (project_id, user_id)
);
-- Enable Row Level Security
ALTER TABLE project_collaborators ENABLE ROW LEVEL SECURITY;
-- RLS: Users can view collaborators for projects they belong to
CREATE POLICY "Users can view collaborators for their projects"
ON project_collaborators
FOR SELECT
USING (
auth.uid() = user_id
OR project_id IN (
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
)
OR project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
);
-- RLS: Project owners can insert collaborators
CREATE POLICY "Owners can add collaborators"
ON project_collaborators
FOR INSERT
WITH CHECK (
project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
);
-- RLS: Project owners can update collaborator roles
CREATE POLICY "Owners can update collaborators"
ON project_collaborators
FOR UPDATE
USING (
project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
)
WITH CHECK (
project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
);
-- RLS: Project owners can remove collaborators
CREATE POLICY "Owners can remove collaborators"
ON project_collaborators
FOR DELETE
USING (
project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
);
-- =============================================================================
-- COLLABORATION SESSIONS TABLE
-- =============================================================================
-- Tracks active editing sessions for real-time presence
CREATE TABLE IF NOT EXISTS collaboration_sessions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
cursor_position jsonb,
selected_node_id text,
connected_at timestamptz DEFAULT now(),
last_heartbeat timestamptz DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE collaboration_sessions ENABLE ROW LEVEL SECURITY;
-- RLS: Users can view sessions for projects they collaborate on
CREATE POLICY "Collaborators can view sessions"
ON collaboration_sessions
FOR SELECT
USING (
project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
OR project_id IN (
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
)
);
-- RLS: Users can insert their own sessions
CREATE POLICY "Users can create own sessions"
ON collaboration_sessions
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- RLS: Users can update their own sessions (heartbeat, cursor position)
CREATE POLICY "Users can update own sessions"
ON collaboration_sessions
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- RLS: Users can delete their own sessions (disconnect)
CREATE POLICY "Users can delete own sessions"
ON collaboration_sessions
FOR DELETE
USING (auth.uid() = user_id);
-- =============================================================================
-- AUDIT TRAIL TABLE
-- =============================================================================
-- Records all node and edge changes for history and revert functionality
CREATE TABLE IF NOT EXISTS audit_trail (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
action_type text NOT NULL CHECK (action_type IN ('node_add', 'node_update', 'node_delete', 'edge_add', 'edge_update', 'edge_delete')),
entity_id text NOT NULL,
previous_state jsonb,
new_state jsonb,
created_at timestamptz DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE audit_trail ENABLE ROW LEVEL SECURITY;
-- RLS: Users can view audit trail for projects they collaborate on
CREATE POLICY "Collaborators can view audit trail"
ON audit_trail
FOR SELECT
USING (
project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
OR project_id IN (
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
)
);
-- RLS: Users can insert audit entries for projects they collaborate on
CREATE POLICY "Collaborators can write audit entries"
ON audit_trail
FOR INSERT
WITH CHECK (
auth.uid() = user_id
AND (
project_id IN (
SELECT id FROM projects WHERE user_id = auth.uid()
)
OR project_id IN (
SELECT project_id FROM project_collaborators
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
)
)
);
-- =============================================================================
-- INDEXES
-- =============================================================================
-- Index for efficient history queries (paginated by time)
CREATE INDEX IF NOT EXISTS idx_audit_trail_project_created
ON audit_trail(project_id, created_at DESC);
-- Index for session lookups by project
CREATE INDEX IF NOT EXISTS idx_collaboration_sessions_project
ON collaboration_sessions(project_id);
-- Index for collaborator lookups by project
CREATE INDEX IF NOT EXISTS idx_project_collaborators_project
ON project_collaborators(project_id);
-- Index for collaborator lookups by user (for "shared with me" queries)
CREATE INDEX IF NOT EXISTS idx_project_collaborators_user
ON project_collaborators(user_id);
-- =============================================================================
-- UPDATE PROJECTS RLS: Allow collaborators to read/write
-- =============================================================================
-- Collaborators with 'editor' or 'owner' role can view the project
CREATE POLICY "Collaborators can view shared projects"
ON projects
FOR SELECT
USING (
id IN (
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
)
);
-- Collaborators with 'editor' or 'owner' role can update the project
CREATE POLICY "Collaborators can update shared projects"
ON projects
FOR UPDATE
USING (
id IN (
SELECT project_id FROM project_collaborators
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
)
)
WITH CHECK (
id IN (
SELECT project_id FROM project_collaborators
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
)
);
@@ -0,0 +1,6 @@
-- Add unique constraint on collaboration_sessions for (project_id, user_id)
-- This ensures a user can only have one active session per project,
-- and allows upsert operations for session management.
ALTER TABLE collaboration_sessions
ADD CONSTRAINT collaboration_sessions_project_user_unique
UNIQUE (project_id, user_id);
@@ -0,0 +1,174 @@
-- Migration: Fix infinite recursion in RLS policies
-- Problem: Self-referencing policies and circular dependencies between
-- projects <-> project_collaborators cause infinite recursion.
-- Solution: Use SECURITY DEFINER functions to bypass RLS for permission checks.
-- =============================================================================
-- HELPER FUNCTIONS (SECURITY DEFINER bypasses RLS)
-- =============================================================================
-- Check if the current user is an admin
CREATE OR REPLACE FUNCTION is_admin()
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND is_admin = true
);
$$;
-- Check if the current user is the owner of a project
CREATE OR REPLACE FUNCTION is_project_owner(p_project_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM projects
WHERE id = p_project_id
AND user_id = auth.uid()
);
$$;
-- Check if the current user is a collaborator on a project (any role)
CREATE OR REPLACE FUNCTION is_project_collaborator(p_project_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM project_collaborators
WHERE project_id = p_project_id
AND user_id = auth.uid()
);
$$;
-- Check if the current user is an editor or owner collaborator on a project
CREATE OR REPLACE FUNCTION is_project_editor(p_project_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM project_collaborators
WHERE project_id = p_project_id
AND user_id = auth.uid()
AND role IN ('owner', 'editor')
);
$$;
-- =============================================================================
-- FIX PROFILES POLICIES
-- =============================================================================
-- Drop the problematic admin policy (self-references profiles table)
DROP POLICY IF EXISTS "Admins can view all profiles" ON profiles;
-- Recreate using the helper function
CREATE POLICY "Admins can view all profiles"
ON profiles
FOR SELECT
USING (is_admin());
-- =============================================================================
-- FIX PROJECT_COLLABORATORS POLICIES
-- =============================================================================
-- Drop the problematic SELECT policy (self-references project_collaborators)
DROP POLICY IF EXISTS "Users can view collaborators for their projects" ON project_collaborators;
-- Recreate without self-reference: user is either the collaborator row's user,
-- the project owner, or already a collaborator on that project
CREATE POLICY "Users can view collaborators for their projects"
ON project_collaborators
FOR SELECT
USING (
auth.uid() = user_id
OR is_project_owner(project_id)
OR is_project_collaborator(project_id)
);
-- Drop and recreate INSERT/UPDATE/DELETE policies to use helper functions
DROP POLICY IF EXISTS "Owners can add collaborators" ON project_collaborators;
CREATE POLICY "Owners can add collaborators"
ON project_collaborators
FOR INSERT
WITH CHECK (is_project_owner(project_id));
DROP POLICY IF EXISTS "Owners can update collaborators" ON project_collaborators;
CREATE POLICY "Owners can update collaborators"
ON project_collaborators
FOR UPDATE
USING (is_project_owner(project_id))
WITH CHECK (is_project_owner(project_id));
DROP POLICY IF EXISTS "Owners can remove collaborators" ON project_collaborators;
CREATE POLICY "Owners can remove collaborators"
ON project_collaborators
FOR DELETE
USING (is_project_owner(project_id));
-- =============================================================================
-- FIX COLLABORATION_SESSIONS POLICIES
-- =============================================================================
-- Drop the SELECT policy that cross-references both projects and project_collaborators
DROP POLICY IF EXISTS "Collaborators can view sessions" ON collaboration_sessions;
CREATE POLICY "Collaborators can view sessions"
ON collaboration_sessions
FOR SELECT
USING (
is_project_owner(project_id)
OR is_project_collaborator(project_id)
);
-- =============================================================================
-- FIX AUDIT_TRAIL POLICIES
-- =============================================================================
DROP POLICY IF EXISTS "Collaborators can view audit trail" ON audit_trail;
CREATE POLICY "Collaborators can view audit trail"
ON audit_trail
FOR SELECT
USING (
is_project_owner(project_id)
OR is_project_collaborator(project_id)
);
DROP POLICY IF EXISTS "Collaborators can write audit entries" ON audit_trail;
CREATE POLICY "Collaborators can write audit entries"
ON audit_trail
FOR INSERT
WITH CHECK (
auth.uid() = user_id
AND (
is_project_owner(project_id)
OR is_project_editor(project_id)
)
);
-- =============================================================================
-- FIX PROJECTS POLICIES (collaborator access)
-- =============================================================================
-- Drop the policies that query project_collaborators (creating circular deps)
DROP POLICY IF EXISTS "Collaborators can view shared projects" ON projects;
CREATE POLICY "Collaborators can view shared projects"
ON projects
FOR SELECT
USING (is_project_collaborator(id));
DROP POLICY IF EXISTS "Collaborators can update shared projects" ON projects;
CREATE POLICY "Collaborators can update shared projects"
ON projects
FOR UPDATE
USING (is_project_editor(id))
WITH CHECK (is_project_editor(id));
@@ -0,0 +1,10 @@
-- Migration: Allow authenticated users to look up profiles for sharing
-- Problem: The profiles RLS policies only allow users to see their own profile
-- (or admins to see all). This prevents the sharing feature from finding
-- users by email since the query is blocked by RLS.
-- Solution: Add a SELECT policy allowing any authenticated user to view profiles.
CREATE POLICY "Authenticated users can view profiles"
ON profiles
FOR SELECT
USING (auth.uid() IS NOT NULL);
@@ -1,530 +0,0 @@
# PRD: Real-time Collaboration & Character/Variable Management
## Introduction
Two major features to enhance the Visual Novel Flowchart Editor:
1. **Real-time Collaboration** - Enable multiple users to work on the same flowchart simultaneously with presence indicators, live cursors, CRDT-based conflict-free synchronization, and a full audit trail with revert capability.
2. **Character & Variable Management** - Replace free-text fields for speaker names and variable names with a centralized management system. Users define characters and variables once per project, then select from dropdowns throughout the flowchart, preventing typos and ensuring consistency.
## Goals
- Enable simultaneous multi-user editing of flowcharts with zero data loss
- Provide real-time awareness of collaborators (presence, cursors, editing state)
- Maintain a full audit trail of all changes with the ability to revert
- Centralize character and variable definitions per project
- Replace free-text inputs with searchable dropdowns for characters/variables
- Auto-migrate existing projects with free-text values to the new system
- Allow importing characters/variables from other projects
---
## User Stories
---
### FEATURE 1: Real-time Collaboration
---
### US-043: Database schema for collaboration sessions and audit trail
**Description:** As a developer, I need database tables to track active collaboration sessions and store the full change history for projects.
**Acceptance Criteria:**
- [ ] Create migration adding `project_collaborators` table: id (uuid), project_id (references projects), user_id (references profiles), role ('owner' | 'editor' | 'viewer'), invited_at (timestamptz), accepted_at (timestamptz)
- [ ] Create `collaboration_sessions` table: id (uuid), project_id, user_id, cursor_position (jsonb), selected_node_id (text nullable), connected_at (timestamptz), last_heartbeat (timestamptz)
- [ ] Create `audit_trail` table: id (uuid), project_id, user_id, action_type (text: 'node_add' | 'node_update' | 'node_delete' | 'edge_add' | 'edge_update' | 'edge_delete'), entity_id (text), previous_state (jsonb), new_state (jsonb), created_at (timestamptz)
- [ ] Add RLS policies: collaborators can access sessions/audit for projects they belong to
- [ ] Add index on audit_trail(project_id, created_at) for efficient history queries
- [ ] Typecheck passes
**Complexity:** L
---
### US-044: Project sharing and collaborator management
**Description:** As a project owner, I want to invite other users to collaborate on my project so that we can work together.
**Acceptance Criteria:**
- [ ] Add "Share" button in the editor toolbar
- [ ] Share modal displays current collaborators with roles (owner/editor/viewer)
- [ ] Owner can invite users by email with a selected role
- [ ] Owner can change collaborator roles or remove collaborators
- [ ] Invited users see shared projects on their dashboard with a "Shared with me" indicator
- [ ] RLS policies updated so collaborators can read/write projects based on their role
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-043
**Complexity:** M
---
### US-045: Supabase Realtime channel and connection management
**Description:** As a developer, I need a WebSocket connection layer using Supabase Realtime so that clients can exchange presence and change events in real time.
**Acceptance Criteria:**
- [ ] Create `lib/collaboration/realtime.ts` module
- [ ] On editor mount, join a Supabase Realtime channel scoped to the project ID
- [ ] Track connection state (connecting, connected, disconnected, reconnecting)
- [ ] Implement heartbeat mechanism (update `last_heartbeat` every 30 seconds)
- [ ] Auto-reconnect on network interruption with exponential backoff
- [ ] Clean up session record on disconnect/unmount
- [ ] Show connection status indicator in editor toolbar (green=connected, yellow=reconnecting, red=disconnected)
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-043
**Complexity:** M
---
### US-046: Presence indicators for active collaborators
**Description:** As a user, I want to see who else is currently viewing or editing the project so that I am aware of my collaborators.
**Acceptance Criteria:**
- [ ] Display a row of avatar circles in the editor toolbar showing connected users
- [ ] Each avatar shows the user's display_name on hover (tooltip)
- [ ] Each user is assigned a consistent color (derived from user ID hash)
- [ ] Avatars appear when users join and disappear when they leave
- [ ] Maximum 5 avatars shown with "+N" overflow indicator
- [ ] Own avatar not shown in the list
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-045
**Complexity:** S
---
### US-047: Live cursor positions on canvas
**Description:** As a user, I want to see other collaborators' cursor positions on the canvas so that I can understand where they are working.
**Acceptance Criteria:**
- [ ] Broadcast local cursor position to the Realtime channel (throttled to 50ms)
- [ ] Render remote cursors as colored arrows/pointers on the canvas with user name labels
- [ ] Cursor color matches the user's assigned presence color
- [ ] Remote cursors smoothly interpolate between position updates (no jumping)
- [ ] Remote cursors fade out after 5 seconds of inactivity
- [ ] Cursors are rendered in screen coordinates and properly transform with canvas zoom/pan
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-045, US-046
**Complexity:** M
---
### US-048: Integrate Yjs CRDT for conflict-free node/edge synchronization
**Description:** As a developer, I need to integrate a CRDT library so that concurrent edits from multiple users merge automatically without data loss.
**Acceptance Criteria:**
- [ ] Install and configure Yjs with a Supabase-compatible provider (or WebSocket provider)
- [ ] Create `lib/collaboration/crdt.ts` module wrapping Yjs document setup
- [ ] Model flowchart nodes as a Y.Map keyed by node ID
- [ ] Model flowchart edges as a Y.Map keyed by edge ID
- [ ] Local React Flow state changes are synced to the Yjs document
- [ ] Remote Yjs document changes update local React Flow state
- [ ] Initial load populates Yjs document from database state
- [ ] Periodic persistence of Yjs document state to Supabase (debounced 2 seconds)
- [ ] Typecheck passes
**Dependencies:** US-045
**Complexity:** L
---
### US-049: Node editing lock indicators
**Description:** As a user, I want to see when another collaborator is actively editing a specific node so that I can avoid conflicts and wait for them to finish.
**Acceptance Criteria:**
- [ ] When a user focuses/opens a node for editing, broadcast the node ID to the channel
- [ ] Nodes being edited by others show a colored border matching the editor's presence color
- [ ] A small label with the editor's name appears on the locked node
- [ ] Other users can still view but see a "Being edited by [name]" indicator if they try to edit
- [ ] Lock is released when the user clicks away, closes the node, or disconnects
- [ ] Lock auto-expires after 60 seconds of inactivity as a safety measure
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-045, US-048
**Complexity:** M
---
### US-050: Join/leave notifications
**Description:** As a user, I want to be notified when collaborators join or leave the editing session so that I stay aware of the team's activity.
**Acceptance Criteria:**
- [ ] Show a toast notification when a collaborator joins: "[Name] joined"
- [ ] Show a toast notification when a collaborator leaves: "[Name] left"
- [ ] Notifications use the collaborator's assigned color as an accent
- [ ] Notifications auto-dismiss after 3 seconds (matches existing Toast behavior)
- [ ] No notification shown for own join/leave events
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-045, US-046
**Complexity:** S
---
### US-051: Audit trail recording
**Description:** As a developer, I need all node and edge changes to be recorded in the audit trail so that users can review history and revert changes.
**Acceptance Criteria:**
- [ ] Every node add/update/delete operation writes a record to `audit_trail` table
- [ ] Every edge add/update/delete operation writes a record to `audit_trail` table
- [ ] Records include `previous_state` (null for additions) and `new_state` (null for deletions)
- [ ] Records include the acting user's ID and timestamp
- [ ] Writes are batched/debounced to avoid excessive DB calls (max 1 write per second per entity)
- [ ] Audit writes do not block the user's editing flow (fire-and-forget with error logging)
- [ ] Typecheck passes
**Dependencies:** US-043, US-048
**Complexity:** M
---
### US-052: Activity history sidebar
**Description:** As a user, I want to view a history of all changes made to the project so that I can see what collaborators have done and when.
**Acceptance Criteria:**
- [ ] Add "History" button to editor toolbar that opens a right sidebar panel
- [ ] Sidebar displays a chronological list of changes with: user name, action type, entity description, timestamp
- [ ] Entries are grouped by time period (Today, Yesterday, Earlier)
- [ ] Each entry shows the user's presence color as an accent
- [ ] Clicking an entry highlights/selects the affected node or edge on the canvas
- [ ] Paginated loading (20 entries per page) with "Load more" button
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-051
**Complexity:** M
---
### US-053: Revert changes from audit trail
**Description:** As a user, I want to revert a specific change from the history so that I can undo mistakes made by myself or collaborators.
**Acceptance Criteria:**
- [ ] Each entry in the activity history sidebar has a "Revert" button
- [ ] Clicking "Revert" shows a confirmation dialog with before/after preview
- [ ] Reverting a node addition deletes the node
- [ ] Reverting a node update restores the previous state
- [ ] Reverting a node deletion re-creates the node with its previous state
- [ ] Reverting an edge change follows the same add/update/delete logic
- [ ] The revert itself is recorded as a new audit trail entry
- [ ] Reverted state is synced to all connected clients via CRDT
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-052, US-048
**Complexity:** L
---
---
### FEATURE 2: Character & Variable Management
---
### US-054: Character and Variable TypeScript types
**Description:** As a developer, I need TypeScript types for Character and Variable models so that the rest of the feature can be built with type safety.
**Acceptance Criteria:**
- [ ] Add `Character` type to `types/flowchart.ts`: id (string), name (string), color (string, hex), description (string, optional)
- [ ] Add `Variable` type to `types/flowchart.ts`: id (string), name (string), type ('numeric' | 'string' | 'boolean'), initialValue (number | string | boolean), description (string, optional)
- [ ] Update `FlowchartData` type to include `characters: Character[]` and `variables: Variable[]`
- [ ] Update `DialogueNodeData` to add optional `characterId: string` field (alongside existing `speaker` for migration)
- [ ] Update `Condition` type to add optional `variableId: string` field (alongside existing `variableName` for migration)
- [ ] Update `VariableNodeData` to add optional `variableId: string` field (alongside existing `variableName` for migration)
- [ ] Typecheck passes
**Complexity:** S
---
### US-055: Database schema update for characters and variables
**Description:** As a developer, I need the database schema to store characters and variables as part of the project's flowchart data.
**Acceptance Criteria:**
- [ ] Create migration that documents the new JSONB structure (characters/variables arrays stored within `flowchart_data`)
- [ ] Update the default value for `flowchart_data` column to include `characters: []` and `variables: []`
- [ ] Existing projects with no characters/variables arrays continue to load (handled as empty arrays in app code)
- [ ] Typecheck passes
**Dependencies:** US-054
**Complexity:** S
---
### US-056: Character management UI in project settings
**Description:** As a user, I want a dedicated page to manage my project's characters so that I can define them once and reuse them throughout the flowchart.
**Acceptance Criteria:**
- [ ] Add "Project Settings" button to editor toolbar
- [ ] Project settings opens as a modal with "Characters" and "Variables" tabs
- [ ] Characters tab shows a list of defined characters with name, color swatch, and description
- [ ] "Add Character" button opens inline form with: name (required), color picker (required, defaults to random), description (optional)
- [ ] Each character row has Edit and Delete buttons
- [ ] Deleting a character that is referenced by nodes shows a warning: "This character is used in N nodes. Removing it will leave those nodes without a speaker."
- [ ] Character names must be unique within the project (show validation error if duplicate)
- [ ] Changes are saved to the flowchart data (same save mechanism as nodes/edges)
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-054, US-055
**Complexity:** M
---
### US-057: Variable management UI in project settings
**Description:** As a user, I want a dedicated page to manage my project's variables so that I can define them with types and initial values for use throughout the flowchart.
**Acceptance Criteria:**
- [ ] Variables tab in project settings modal shows a list of defined variables
- [ ] Each variable displays: name, type badge (numeric/string/boolean), initial value, description
- [ ] "Add Variable" button opens inline form with: name (required), type dropdown (required), initial value (required, input type matches selected type), description (optional)
- [ ] Each variable row has Edit and Delete buttons
- [ ] Deleting a variable that is referenced by nodes/edges shows a warning with usage count
- [ ] Variable names must be unique within the project
- [ ] Changes are saved to the flowchart data
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-054, US-055
**Complexity:** M
---
### US-058: Dialogue node speaker dropdown
**Description:** As a user, I want to select a character from a dropdown in the dialogue node instead of typing a name so that I avoid typos and maintain consistency.
**Acceptance Criteria:**
- [ ] Replace the speaker text input in DialogueNode with a searchable dropdown (combobox)
- [ ] Dropdown lists all characters defined in the project, showing color swatch + name
- [ ] Selecting a character sets `characterId` on the node data
- [ ] Dropdown includes an "Add new character..." option at the bottom
- [ ] Clicking "Add new character..." opens a mini form inline (name + color) that creates the character and selects it
- [ ] If node has a `characterId` that doesn't match any defined character, show orange warning border on the dropdown
- [ ] Empty/unset speaker shows placeholder "Select speaker..."
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-056
**Complexity:** M
---
### US-059: Variable node variable dropdown
**Description:** As a user, I want to select a variable from a dropdown in the variable node instead of typing a name so that I avoid typos and maintain consistency.
**Acceptance Criteria:**
- [ ] Replace the variableName text input in VariableNode with a searchable dropdown
- [ ] Dropdown lists all variables defined in the project, showing type badge + name
- [ ] Selecting a variable sets `variableId` on the node data
- [ ] Dropdown includes "Add new variable..." option that opens inline creation form
- [ ] If node references a variableId that doesn't match any defined variable, show orange warning border
- [ ] Operation options (set/add/subtract) are filtered based on selected variable's type (add/subtract only for numeric)
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-057
**Complexity:** M
---
### US-060: Edge condition variable dropdown
**Description:** As a user, I want to select a variable from a dropdown when setting edge conditions so that I reference valid variables consistently.
**Acceptance Criteria:**
- [ ] Replace the variableName text input in ConditionEditor with a searchable dropdown
- [ ] Dropdown lists all variables defined in the project, showing type badge + name
- [ ] Selecting a variable sets `variableId` on the condition object
- [ ] Dropdown includes "Add new variable..." option
- [ ] If condition references an undefined variableId, show orange warning indicator
- [ ] Operator options are filtered based on variable type (comparison operators for numeric, == and != for string/boolean)
- [ ] Value input adapts to variable type (number input for numeric, text for string, checkbox for boolean)
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-057
**Complexity:** M
---
### US-061: Choice option condition variable dropdown
**Description:** As a user, I want to select a variable from a dropdown when setting choice option conditions so that I reference valid variables consistently.
**Acceptance Criteria:**
- [ ] Replace the variableName text input in OptionConditionEditor with a searchable dropdown
- [ ] Dropdown lists all variables defined in the project, showing type badge + name
- [ ] Selecting a variable sets `variableId` on the option's condition object
- [ ] Dropdown includes "Add new variable..." option
- [ ] If condition references an undefined variableId, show orange warning indicator
- [ ] Operator and value inputs adapt to variable type (same behavior as US-060)
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-057
**Complexity:** S
---
### US-062: Auto-migration of existing free-text values
**Description:** As a user, I want my existing projects to automatically create character and variable definitions from free-text values so that I don't have to manually re-enter them.
**Acceptance Criteria:**
- [ ] On project load, if `characters` array is empty but nodes have `speaker` values, auto-create Character entries from unique speaker names
- [ ] Auto-created characters get randomly assigned colors and the speaker text as name
- [ ] On project load, if `variables` array is empty but nodes/edges have `variableName` values, auto-create Variable entries (default type: numeric, initial value: 0)
- [ ] After auto-creation, update all nodes to set `characterId`/`variableId` references pointing to the new entries
- [ ] Show a toast notification: "Auto-imported N characters and M variables from existing data"
- [ ] Migration only runs once (presence of characters/variables arrays, even if empty, means migration already happened)
- [ ] Typecheck passes
**Dependencies:** US-054, US-058, US-059
**Complexity:** M
---
### US-063: Import characters/variables from another project
**Description:** As a user, I want to import character and variable definitions from another project so that I can reuse them without redefining everything.
**Acceptance Criteria:**
- [ ] Add "Import from project" button in both Characters and Variables tabs of project settings
- [ ] Button opens a modal listing the user's other projects
- [ ] Selecting a project shows its characters (or variables) with checkboxes for selection
- [ ] User can select which entries to import (select all / none / individual)
- [ ] Imported entries are added to the current project (duplicates by name are skipped with a warning)
- [ ] Imported characters keep their colors; imported variables keep their types and initial values
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-056, US-057
**Complexity:** M
---
### US-064: Export validation for undefined references
**Description:** As a user, I want to be warned before exporting if any nodes reference undefined characters or variables so that I can fix issues before generating output.
**Acceptance Criteria:**
- [ ] Before export, scan all nodes and edges for `characterId`/`variableId` references that don't match defined entries
- [ ] If issues found, show a warning modal listing: node type, node content snippet, and the undefined reference
- [ ] Modal offers "Export anyway" and "Cancel" options
- [ ] Nodes with undefined references are highlighted on the canvas with orange warning borders when modal is shown
- [ ] If no issues found, export proceeds normally
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Dependencies:** US-058, US-059, US-060, US-061
**Complexity:** M
---
### US-065: Searchable combobox component
**Description:** As a developer, I need a reusable searchable combobox component so that all character/variable dropdowns share consistent behavior and styling.
**Acceptance Criteria:**
- [ ] Create `components/editor/Combobox.tsx` - a reusable searchable dropdown component
- [ ] Props: items (id, label, color?, badge?), value, onChange, placeholder, onAddNew (optional callback)
- [ ] Typing in the input filters the list by name (case-insensitive)
- [ ] Keyboard navigation: arrow keys to move, Enter to select, Escape to close
- [ ] Shows color swatch and/or badge next to item labels when provided
- [ ] "Add new..." option rendered at bottom when `onAddNew` prop is provided
- [ ] Dropdown positions itself above or below input based on available space
- [ ] Matches existing editor styling (TailwindCSS, dark mode support)
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
**Complexity:** M
---
## Functional Requirements
### Real-time Collaboration
- FR-1: The system must establish a WebSocket connection via Supabase Realtime when a user opens a project in the editor
- FR-2: The system must broadcast user presence (cursor position, selected node, connected status) to all collaborators on the same project
- FR-3: The system must display up to 5 collaborator avatars in the toolbar with overflow count
- FR-4: The system must render remote cursors on the canvas with color-coded labels
- FR-5: The system must use Yjs CRDT documents to synchronize node and edge state across clients without data loss
- FR-6: The system must show a colored border and name label on nodes being actively edited by another user
- FR-7: The system must display toast notifications when collaborators join or leave the session
- FR-8: The system must record all node/edge mutations to an audit_trail table with previous and new state
- FR-9: The system must provide a history sidebar showing chronological change entries grouped by date
- FR-10: The system must allow reverting any individual change from the history, restoring the previous state
- FR-11: The system must handle network disconnection gracefully with automatic reconnection and state re-sync
- FR-12: The system must allow project owners to invite collaborators by email with a specific role (editor/viewer)
### Character & Variable Management
- FR-13: The system must store Character definitions (id, name, color, description) in the project's flowchart_data
- FR-14: The system must store Variable definitions (id, name, type, initialValue, description) in the project's flowchart_data
- FR-15: The system must provide a project settings modal with tabs for managing characters and variables
- FR-16: The system must replace all speaker text inputs with searchable combobox dropdowns populated from the characters list
- FR-17: The system must replace all variableName text inputs with searchable combobox dropdowns populated from the variables list
- FR-18: The system must allow inline creation of new characters/variables from within dropdowns
- FR-19: The system must show orange warning indicators on nodes/edges referencing undefined characters or variables
- FR-20: The system must validate references before export and warn the user of any undefined references
- FR-21: The system must auto-migrate existing free-text speaker/variableName values to Character/Variable definitions on first load
- FR-22: The system must allow importing characters/variables from other projects owned by the user
- FR-23: The system must filter operator options and value inputs based on the selected variable's type
## Non-Goals (Out of Scope)
- No voice/video chat between collaborators
- No commenting/annotation system on nodes
- No permission system beyond owner/editor/viewer roles
- No offline-first editing (requires active connection for collaboration)
- No version branching or forking (linear history only)
- No global character/variable library shared across all users
- No AI-assisted character or variable suggestions
- No real-time text editing within a single node's text field (CRDT operates at node level)
- No variable dependencies or computed values
## Design Considerations
- Presence avatars should follow existing toolbar button styling (compact, consistent height)
- Remote cursors should be semi-transparent to avoid visual clutter
- Node lock indicators should use the existing node border styling with color override
- Project settings modal should match existing modal patterns (ProjectCard rename/delete modals)
- Combobox dropdown should match existing input styling with dark mode support
- Character colors should be distinguishable in both light and dark modes
- History sidebar width should not exceed 320px to preserve canvas space
## Technical Considerations
- **Supabase Realtime** for presence and broadcast channels (already available in the stack)
- **Yjs** CRDT library for conflict-free document synchronization
- **y-supabase** or custom provider to bridge Yjs with Supabase Realtime
- Cursor position broadcasts should be throttled (50ms) to avoid overwhelming the channel
- Audit trail writes should be debounced and batched to minimize database load
- Auto-migration logic must be idempotent (safe to run multiple times)
- Combobox component should use React portal for dropdown to avoid z-index/overflow issues
- Character/variable ID references enable renaming without breaking node associations
- The `flowchart_data` JSONB column grows with characters/variables arrays; monitor size for large projects
## Success Metrics
- Multiple users can simultaneously edit a flowchart with all changes reflected within 500ms
- No data loss during concurrent edits (CRDT guarantees)
- Collaborators can see each other's presence and activity within 1 second of connection
- Any historical change can be reverted without side effects on other nodes
- Zero typo-related issues in character names and variable references after migration to dropdowns
- New characters/variables can be created inline without leaving the canvas (under 3 clicks)
- Existing projects auto-migrate to the new system seamlessly on first load
## Open Questions
- Should the Yjs document be persisted separately from the Supabase JSONB column (dedicated storage)?
- What is the maximum number of concurrent collaborators to support per project?
- Should viewer-role users see other viewers' cursors, or only editors'?
- How long should audit trail entries be retained (indefinitely, or time-based cleanup)?
- Should the combobox support creating characters/variables with full details inline, or just name + essentials?
- Should variable type changes be allowed after creation if the variable is already in use?