Merge pull request 'ralph/collaboration-and-character-variables' (#9) from ralph/collaboration-and-character-variables into developing

Reviewed-on: #9
This commit is contained in:
GHMiranda 2026-01-25 00:11:20 +00:00
commit 75750cf226
18 changed files with 1924 additions and 114 deletions

View File

@ -324,7 +324,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 18,
"passes": false,
"passes": true,
"notes": "Dependencies: US-045, US-046"
},
{
@ -341,7 +341,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 19,
"passes": false,
"passes": true,
"notes": "Dependencies: US-045, US-046"
},
{
@ -359,7 +359,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 20,
"passes": false,
"passes": true,
"notes": "Dependencies: US-045, US-048"
},
{
@ -376,7 +376,7 @@
"Typecheck passes"
],
"priority": 21,
"passes": false,
"passes": true,
"notes": "Dependencies: US-043, US-048"
},
{
@ -394,7 +394,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 22,
"passes": false,
"passes": true,
"notes": "Dependencies: US-051"
},
{
@ -414,7 +414,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 23,
"passes": false,
"passes": true,
"notes": "Dependencies: US-052, US-048"
}
]

View File

@ -60,6 +60,16 @@
- `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.
---
@ -396,3 +406,102 @@
- 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
---
## 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.
---

View File

@ -33,16 +33,21 @@ import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
import ConditionEditor from '@/components/editor/ConditionEditor'
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
import { EditorProvider } from '@/components/editor/EditorContext'
import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime'
import { RealtimeConnection, type ConnectionState, type PresenceUser, type RemoteCursor, type NodeLock } from '@/lib/collaboration/realtime'
import type { NodeLockInfo } from '@/components/editor/EditorContext'
import RemoteCursors from '@/components/editor/RemoteCursors'
import { CRDTManager } from '@/lib/collaboration/crdt'
import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail'
import ShareModal from '@/components/editor/ShareModal'
import ActivityHistorySidebar, { type AuditEntry } from '@/components/editor/ActivityHistorySidebar'
import CollaborationToast, { type CollaborationNotification } from '@/components/editor/CollaborationToast'
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
// LocalStorage key prefix for draft saves
const DRAFT_KEY_PREFIX = 'vnwrite-draft-'
// Debounce delay in ms
const AUTOSAVE_DEBOUNCE_MS = 1000
// Debounce delay for LocalStorage draft saves
const AUTOSAVE_DEBOUNCE_MS = 5000
type ContextMenuState = {
x: number
@ -302,6 +307,28 @@ function randomHexColor(): string {
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
}
// Generate a consistent color from a user ID hash (same logic 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
}
return RANDOM_COLORS[Math.abs(hash) % RANDOM_COLORS.length]
}
// Determine the action type for a revert audit entry
function getRevertActionType(originalActionType: string): string {
switch (originalActionType) {
case 'node_add': return 'node_delete' // reverting an add = deleting
case 'node_delete': return 'node_add' // reverting a delete = adding back
case 'node_update': return 'node_update' // reverting an update = updating back
case 'edge_add': return 'edge_delete'
case 'edge_delete': return 'edge_add'
case 'edge_update': return 'edge_update'
default: return originalActionType
}
}
// Compute auto-migration of existing free-text values to character/variable definitions
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
if (!shouldMigrate) {
@ -500,17 +527,31 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
// Refs to always have the latest characters/variables for the CRDT persist callback
const charactersRef = useRef(characters)
const variablesRef = useRef(variables)
charactersRef.current = characters
variablesRef.current = variables
const [showSettings, setShowSettings] = useState(false)
const [showShare, setShowShare] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
const [remoteCursors, setRemoteCursors] = useState<RemoteCursor[]>([])
const realtimeRef = useRef<RealtimeConnection | null>(null)
const crdtRef = useRef<CRDTManager | null>(null)
const isRemoteUpdateRef = useRef(false)
const auditRef = useRef<AuditTrailRecorder | null>(null)
const cursorThrottleRef = useRef<number>(0)
const [collaborationNotifications, setCollaborationNotifications] = useState<CollaborationNotification[]>([])
const [nodeLocks, setNodeLocks] = useState<Map<string, NodeLockInfo>>(new Map())
const localLockRef = useRef<string | null>(null) // node ID currently locked by this user
const lockExpiryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const isRevertingRef = useRef(false) // guards against double audit recording during revert
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
useEffect(() => {
@ -518,25 +559,26 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
const crdtManager = new CRDTManager({
onNodesChange: (crdtNodes: FlowchartNode[]) => {
isRemoteUpdateRef.current = true
setNodes(toReactFlowNodes(crdtNodes))
isRemoteUpdateRef.current = false
},
onEdgesChange: (crdtEdges: FlowchartEdge[]) => {
isRemoteUpdateRef.current = true
setEdges(toReactFlowEdges(crdtEdges))
isRemoteUpdateRef.current = false
},
onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => {
try {
// Auto-persist saves the current state for durability. We do NOT
// broadcast state-refresh here because CRDT already syncs nodes/edges
// via yjs-update broadcasts. Broadcasting here causes ping-pong:
// other clients fetch from DB, overwrite their local variables/characters,
// then their persist writes stale data back, causing a loop.
await supabase
.from('projects')
.update({
flowchart_data: {
nodes: persistNodes,
edges: persistEdges,
characters,
variables,
characters: charactersRef.current,
variables: variablesRef.current,
},
})
.eq('id', projectId)
@ -550,9 +592,78 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
crdtManager.initializeFromData(migratedData.nodes, migratedData.edges)
crdtRef.current = crdtManager
// Initialize audit trail recorder
const auditRecorder = new AuditTrailRecorder(projectId, userId)
auditRecorder.initialize(migratedData.nodes, migratedData.edges)
auditRef.current = auditRecorder
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
onConnectionStateChange: setConnectionState,
onPresenceSync: setPresenceUsers,
onPresenceJoin: (user) => {
setCollaborationNotifications((prev) => [
...prev,
{ id: nanoid(), displayName: user.displayName, type: 'join', color: getUserColor(user.userId) },
])
// Send full CRDT state so the joining client gets caught up
crdtManager.broadcastFullState()
},
onPresenceLeave: (user) => {
setCollaborationNotifications((prev) => [
...prev,
{ id: nanoid(), displayName: user.displayName, type: 'leave', color: getUserColor(user.userId) },
])
},
onCursorUpdate: (cursor) => {
setRemoteCursors((prev) => {
const existing = prev.findIndex((c) => c.userId === cursor.userId)
if (existing >= 0) {
const updated = [...prev]
updated[existing] = cursor
return updated
}
return [...prev, cursor]
})
},
onNodeLockUpdate: (lock: NodeLock | null, lockUserId: string) => {
setNodeLocks((prev) => {
const next = new Map(prev)
if (lock) {
next.set(lock.nodeId, { ...lock, color: getUserColor(lock.userId) })
} else {
// Remove any lock held by this user
for (const [nodeId, info] of next) {
if (info.userId === lockUserId) {
next.delete(nodeId)
}
}
}
return next
})
},
onCRDTUpdate: (update: number[]) => {
crdtManager.applyRemoteUpdate(update)
},
onStateRefresh: async () => {
try {
const sb = createClient()
const { data } = await sb
.from('projects')
.select('flowchart_data')
.eq('id', projectId)
.single()
if (data?.flowchart_data) {
const fd = data.flowchart_data as FlowchartData
setNodes(toReactFlowNodes(fd.nodes))
setEdges(toReactFlowEdges(fd.edges))
setCharacters(fd.characters)
setVariables(fd.variables)
crdtManager.refreshFromData(fd.nodes, fd.edges)
}
} catch {
// Non-critical: user can still manually refresh
}
},
onChannelSubscribed: (channel) => {
crdtManager.connectChannel(channel)
},
@ -561,14 +672,53 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
connection.connect()
return () => {
// Release any held lock before disconnecting
if (localLockRef.current) {
connection.broadcastNodeLock(null)
localLockRef.current = null
}
connection.disconnect()
realtimeRef.current = null
crdtManager.destroy()
crdtRef.current = null
auditRecorder.destroy()
auditRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, userId, userDisplayName])
// Manage connection lifecycle based on visibility and user activity
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
realtimeRef.current?.notifyVisibilityResumed()
}
}
// Throttle activity notifications to avoid excessive calls
let activityThrottled = false
const throttledActivity = () => {
if (activityThrottled) return
activityThrottled = true
realtimeRef.current?.notifyActivity()
setTimeout(() => { activityThrottled = false }, 10_000)
}
document.addEventListener('visibilitychange', handleVisibilityChange)
document.addEventListener('mousedown', throttledActivity)
document.addEventListener('keydown', throttledActivity)
document.addEventListener('scroll', throttledActivity, true)
document.addEventListener('mousemove', throttledActivity)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
document.removeEventListener('mousedown', throttledActivity)
document.removeEventListener('keydown', throttledActivity)
document.removeEventListener('scroll', throttledActivity, true)
document.removeEventListener('mousemove', throttledActivity)
}
}, [])
// Sync local React Flow state changes to CRDT (skip remote-originated updates)
const nodesForCRDT = useMemo(() => {
return nodes.map((node) => ({
@ -591,15 +741,223 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
}, [edges])
useEffect(() => {
if (isRemoteUpdateRef.current) return
crdtRef.current?.updateNodes(nodesForCRDT)
if (!isRevertingRef.current) {
auditRef.current?.recordNodeChanges(nodesForCRDT)
}
}, [nodesForCRDT])
useEffect(() => {
if (isRemoteUpdateRef.current) return
crdtRef.current?.updateEdges(edgesForCRDT)
if (!isRevertingRef.current) {
auditRef.current?.recordEdgeChanges(edgesForCRDT)
}
}, [edgesForCRDT])
// Broadcast cursor position on mouse move (throttled to 50ms)
const handleMouseMove = useCallback(
(event: React.MouseEvent) => {
const now = Date.now()
if (now - cursorThrottleRef.current < 50) return
cursorThrottleRef.current = now
const connection = realtimeRef.current
if (!connection) return
// Convert screen position to flow coordinates
const bounds = event.currentTarget.getBoundingClientRect()
const screenX = event.clientX - bounds.left
const screenY = event.clientY - bounds.top
const flowPosition = screenToFlowPosition({ x: screenX, y: screenY })
connection.broadcastCursor(flowPosition)
},
[screenToFlowPosition]
)
// Remove cursors and locks for users who leave
useEffect(() => {
setRemoteCursors((prev) =>
prev.filter((cursor) =>
presenceUsers.some((u) => u.userId === cursor.userId)
)
)
setNodeLocks((prev) => {
const next = new Map(prev)
let changed = false
for (const [nodeId, info] of next) {
if (!presenceUsers.some((u) => u.userId === info.userId)) {
next.delete(nodeId)
changed = true
}
}
return changed ? next : prev
})
}, [presenceUsers])
// Lock auto-expiry: expire locks after 60 seconds of inactivity
useEffect(() => {
const LOCK_EXPIRY_MS = 60_000
lockExpiryTimerRef.current = setInterval(() => {
const now = Date.now()
setNodeLocks((prev) => {
const next = new Map(prev)
let changed = false
for (const [nodeId, info] of next) {
if (now - info.lockedAt > LOCK_EXPIRY_MS) {
next.delete(nodeId)
changed = true
}
}
return changed ? next : prev
})
}, 5000) // Check every 5 seconds
return () => {
if (lockExpiryTimerRef.current) {
clearInterval(lockExpiryTimerRef.current)
lockExpiryTimerRef.current = null
}
}
}, [])
const handleDismissNotification = useCallback((id: string) => {
setCollaborationNotifications((prev) => prev.filter((n) => n.id !== id))
}, [])
const handleHistorySelectEntity = useCallback((entityId: string, actionType: string) => {
if (actionType.startsWith('node_')) {
// Select the node on the canvas
setNodes((nds) =>
nds.map((n) => ({ ...n, selected: n.id === entityId }))
)
setEdges((eds) =>
eds.map((e) => ({ ...e, selected: false }))
)
} else if (actionType.startsWith('edge_')) {
// Select the edge on the canvas
setEdges((eds) =>
eds.map((e) => ({ ...e, selected: e.id === entityId }))
)
setNodes((nds) =>
nds.map((n) => ({ ...n, selected: false }))
)
}
}, [setNodes, setEdges])
const handleRevertEntry = useCallback((entry: AuditEntry) => {
const { action_type, entity_id, previous_state, new_state } = entry
// Guard against double audit recording - we write our own audit entry for the revert
isRevertingRef.current = true
// Reset after React has processed the state updates
setTimeout(() => { isRevertingRef.current = false }, 0)
if (action_type === 'node_add') {
// Revert an addition = delete the node
setNodes((nds) => nds.filter((n) => n.id !== entity_id))
// Also remove any edges connected to this node
setEdges((eds) => eds.filter((e) => e.source !== entity_id && e.target !== entity_id))
} else if (action_type === 'node_update') {
// Revert an update = restore previous state
if (previous_state) {
const prevNode = previous_state as unknown as FlowchartNode
setNodes((nds) =>
nds.map((n) =>
n.id === entity_id
? { ...n, position: prevNode.position, data: prevNode.data }
: n
)
)
}
} else if (action_type === 'node_delete') {
// Revert a deletion = re-create the node from previous_state
if (previous_state) {
const prevNode = previous_state as unknown as FlowchartNode
const newReactFlowNode: Node = {
id: prevNode.id,
type: prevNode.type,
position: prevNode.position,
data: prevNode.data,
}
setNodes((nds) => [...nds, newReactFlowNode])
}
} else if (action_type === 'edge_add') {
// Revert an edge addition = delete the edge
setEdges((eds) => eds.filter((e) => e.id !== entity_id))
} else if (action_type === 'edge_update') {
// Revert an edge update = restore previous state
if (previous_state) {
const prevEdge = previous_state as unknown as FlowchartEdge
setEdges((eds) =>
eds.map((e) =>
e.id === entity_id
? {
...e,
source: prevEdge.source,
sourceHandle: prevEdge.sourceHandle,
target: prevEdge.target,
targetHandle: prevEdge.targetHandle,
data: prevEdge.data,
}
: e
)
)
}
} else if (action_type === 'edge_delete') {
// Revert an edge deletion = re-create the edge from previous_state
if (previous_state) {
const prevEdge = previous_state as unknown as FlowchartEdge
const newReactFlowEdge: Edge = {
id: prevEdge.id,
source: prevEdge.source,
sourceHandle: prevEdge.sourceHandle,
target: prevEdge.target,
targetHandle: prevEdge.targetHandle,
data: prevEdge.data,
type: 'conditional',
markerEnd: { type: MarkerType.ArrowClosed },
}
setEdges((eds) => [...eds, newReactFlowEdge])
}
}
// Record the revert as a new audit trail entry (fire-and-forget)
const supabase = createClient()
const revertActionType = getRevertActionType(action_type)
supabase
.from('audit_trail')
.insert({
project_id: projectId,
user_id: userId,
action_type: revertActionType,
entity_id,
previous_state: new_state, // "previous" for the revert is the current state (which was new_state)
new_state: previous_state, // "new" for the revert is what we're restoring to
})
.then(({ error }) => {
if (error) {
console.error('[Revert] Failed to write revert audit entry:', error)
}
})
setToast({ message: 'Change reverted successfully', type: 'success' })
}, [setNodes, setEdges, projectId, userId])
const handleNodeFocus = useCallback((nodeId: string) => {
// Broadcast lock for this node
localLockRef.current = nodeId
realtimeRef.current?.broadcastNodeLock(nodeId)
}, [])
const handleNodeBlur = useCallback(() => {
// Release lock
if (localLockRef.current) {
localLockRef.current = null
realtimeRef.current?.broadcastNodeLock(null)
}
}, [])
const handleAddCharacter = useCallback(
(name: string, color: string): string => {
const id = nanoid()
@ -621,8 +979,16 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
)
const editorContextValue = useMemo(
() => ({ characters, onAddCharacter: handleAddCharacter, variables, onAddVariable: handleAddVariableDefinition }),
[characters, handleAddCharacter, variables, handleAddVariableDefinition]
() => ({
characters,
onAddCharacter: handleAddCharacter,
variables,
onAddVariable: handleAddVariableDefinition,
nodeLocks,
onNodeFocus: handleNodeFocus,
onNodeBlur: handleNodeBlur,
}),
[characters, handleAddCharacter, variables, handleAddVariableDefinition, nodeLocks, handleNodeFocus, handleNodeBlur]
)
const getCharacterUsageCount = useCallback(
@ -836,6 +1202,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
// Update last saved data ref to mark as not dirty
lastSavedDataRef.current = flowchartData
// Notify other connected clients to refresh from the database
realtimeRef.current?.broadcastStateRefresh()
setToast({ message: 'Project saved successfully', type: 'success' })
} catch (error) {
console.error('Failed to save project:', error)
@ -1207,10 +1576,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
onImport={handleImport}
onProjectSettings={() => setShowSettings(true)}
onShare={() => setShowShare(true)}
onHistory={() => setShowHistory((v) => !v)}
connectionState={connectionState}
presenceUsers={presenceUsers}
/>
<div className="flex-1">
<div className="relative flex-1" onMouseMove={handleMouseMove}>
<ReactFlow
nodes={styledNodes}
edges={edges}
@ -1231,6 +1601,15 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls position="bottom-right" />
</ReactFlow>
<RemoteCursors cursors={remoteCursors} />
{showHistory && (
<ActivityHistorySidebar
projectId={projectId}
onClose={() => setShowHistory(false)}
onSelectEntity={handleHistorySelectEntity}
onRevert={handleRevertEntry}
/>
)}
</div>
{contextMenu && (
<ContextMenu
@ -1288,6 +1667,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
onClose={() => setToastMessage(null)}
/>
)}
{collaborationNotifications.length > 0 && (
<div className="fixed bottom-4 left-4 z-50 flex flex-col gap-2">
{collaborationNotifications.map((notification) => (
<CollaborationToast
key={notification.id}
notification={notification}
onDismiss={handleDismissNotification}
/>
))}
</div>
)}
</div>
</EditorProvider>
)

View File

@ -41,7 +41,6 @@ export default async function EditorPage({ params }: PageProps) {
// If not the owner, check if the user is a collaborator
if (!project) {
// RLS on projects allows collaborators to SELECT shared projects
const { data: collab } = await supabase
.from('project_collaborators')
.select('id, role')
@ -49,37 +48,20 @@ export default async function EditorPage({ params }: PageProps) {
.eq('user_id', user.id)
.single()
if (!collab) {
notFound()
}
if (collab) {
const { data: sharedProject } = await supabase
.from('projects')
.select('id, name, flowchart_data')
.eq('id', projectId)
.single()
if (!sharedProject) {
notFound()
}
project = sharedProject
isOwner = false
}
const rawData = project.flowchart_data || {}
const flowchartData: FlowchartData = {
nodes: rawData.nodes || [],
edges: rawData.edges || [],
characters: rawData.characters || [],
variables: rawData.variables || [],
}
// Migration flag: if the raw data doesn't have characters/variables arrays,
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return (<>
if (!project) {
return (
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
@ -101,6 +83,7 @@ export default async function EditorPage({ params }: PageProps) {
/>
</svg>
</Link>
</div>
</header>
<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">
@ -131,32 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
</Link>
</div>
</div>
</header>
</div>
)
}
<div className="flex-1">
const rawData = project.flowchart_data || {}
const flowchartData: FlowchartData = {
nodes: rawData.nodes || [],
edges: rawData.edges || [],
characters: rawData.characters || [],
variables: rawData.variables || [],
}
// Migration flag: if the raw data doesn't have characters/variables arrays,
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return (
<FlowchartEditor
projectId={project.id}
projectName={project.name}
userId={user.id}
userDisplayName={userDisplayName}
isOwner={isOwner}
initialData={flowchartData}
needsMigration={needsMigration}
/>
</div>
</>
)
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
return (
<FlowchartEditor
projectId={project.id}
projectName={project.name}
initialData={flowchartData}
/>
)
}

View File

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

View File

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

View File

@ -2,12 +2,18 @@
import { createContext, useContext } from 'react'
import type { Character, Variable } from '@/types/flowchart'
import type { NodeLock } from '@/lib/collaboration/realtime'
export type NodeLockInfo = NodeLock & { color: string }
type EditorContextValue = {
characters: Character[]
onAddCharacter: (name: string, color: string) => string // returns new character id
variables: Variable[]
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
nodeLocks: Map<string, NodeLockInfo> // nodeId -> lock info
onNodeFocus: (nodeId: string) => void
onNodeBlur: () => void
}
const EditorContext = createContext<EditorContextValue>({
@ -15,6 +21,9 @@ const EditorContext = createContext<EditorContextValue>({
onAddCharacter: () => '',
variables: [],
onAddVariable: () => '',
nodeLocks: new Map(),
onNodeFocus: () => {},
onNodeBlur: () => {},
})
export const EditorProvider = EditorContext.Provider

View File

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

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

View File

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

View File

@ -15,6 +15,7 @@ type ToolbarProps = {
onImport: () => void
onProjectSettings: () => void
onShare: () => void
onHistory: () => void
connectionState?: ConnectionState
presenceUsers?: PresenceUser[]
}
@ -44,6 +45,7 @@ export default function Toolbar({
onImport,
onProjectSettings,
onShare,
onHistory,
connectionState,
presenceUsers,
}: ToolbarProps) {
@ -108,6 +110,12 @@ export default function Toolbar({
>
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
onClick={onSave}
disabled={isSaving}

View File

@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
import { useEditorContext } from '@/components/editor/EditorContext'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
import type { Condition } from '@/types/flowchart'
type ChoiceOption = {
@ -23,9 +24,12 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow()
const { variables } = useEditorContext() // Puxa as variáveis globais para validar condições
const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
// --- Handlers de Atualização ---
const updatePrompt = useCallback(
@ -152,8 +156,26 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[variables]
)
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return (
<div className="min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900">
<div
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"
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 type="target" position={Position.Top} className="!bg-green-500" />
<div className="mb-2 flex items-center justify-between">

View File

@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type DialogueNodeData = {
speaker?: string
@ -24,7 +25,10 @@ function randomColor(): string {
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
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 [newName, setNewName] = useState('')
@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
[updateNodeData]
)
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return (
<div
className={`min-w-[200px] rounded-lg border-2 ${
className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-blue-500 dark:border-blue-400'
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
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
type="target"
position={Position.Top}

View File

@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type VariableNodeData = {
variableName: string
@ -15,7 +16,10 @@ type VariableNodeData = {
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
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 [newName, setNewName] = useState('')
@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
// Filter operations based on selected variable type
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return (
<div
className={`min-w-[200px] rounded-lg border-2 ${
className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-orange-500 dark:border-orange-400'
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
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
type="target"
position={Position.Top}

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

View File

@ -2,7 +2,7 @@ import * as Y from 'yjs'
import type { RealtimeChannel } from '@supabase/supabase-js'
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
const PERSIST_DEBOUNCE_MS = 2000
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 = {
@ -19,6 +19,7 @@ export class CRDTManager {
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) {
@ -29,24 +30,25 @@ export class CRDTManager {
// Schedule persistence on local Yjs document changes
this.nodesMap.observe(() => {
if (this.isApplyingRemote) return
if (this.isApplyingRemote || this.isSuppressed) return
this.schedulePersist()
})
this.edgesMap.observe(() => {
if (this.isApplyingRemote) return
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') return // Don't re-broadcast remote updates
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))
@ -55,27 +57,70 @@ export class CRDTManager {
this.edgesMap.set(edge.id, JSON.stringify(edge))
})
}, 'init')
this.isSuppressed = false
}
/** Connect to a Supabase Realtime channel for syncing updates */
/** 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()
}
// Listen for broadcast updates from other clients
channel.on('broadcast', { event: BROADCAST_EVENT }, (payload) => {
/** 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 data = payload.payload as { update?: number[] } | undefined
if (data?.update) {
const update = new Uint8Array(data.update)
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()
this.schedulePersist()
// 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 */

View File

@ -8,26 +8,58 @@ export type PresenceUser = {
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) {
@ -39,7 +71,36 @@ export class RealtimeConnection {
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 } },
@ -63,8 +124,62 @@ export class RealtimeConnection {
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
@ -84,15 +199,24 @@ export class RealtimeConnection {
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()
@ -103,10 +227,133 @@ export class RealtimeConnection {
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(
@ -139,19 +386,44 @@ export class RealtimeConnection {
private startHeartbeat(): void {
this.stopHeartbeat()
let consecutiveFailures = 0
this.heartbeatTimer = setInterval(async () => {
if (this.isDestroyed) {
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 {
// Heartbeat failure is non-critical
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)
}
@ -192,4 +464,28 @@ export class RealtimeConnection {
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
}
}
}

View File

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