ralph/collaboration-and-character-variables #9
12
prd.json
12
prd.json
|
|
@ -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,7 +414,7 @@
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 23,
|
"priority": 23,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-052, US-048"
|
"notes": "Dependencies: US-052, US-048"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
109
progress.txt
109
progress.txt
|
|
@ -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.
|
- `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.
|
- 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 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)
|
- 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.
|
||||||
|
---
|
||||||
|
|
|
||||||
|
|
@ -33,16 +33,21 @@ import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal'
|
||||||
import ConditionEditor from '@/components/editor/ConditionEditor'
|
import ConditionEditor from '@/components/editor/ConditionEditor'
|
||||||
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal'
|
||||||
import { EditorProvider } from '@/components/editor/EditorContext'
|
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 { CRDTManager } from '@/lib/collaboration/crdt'
|
||||||
|
import { AuditTrailRecorder } from '@/lib/collaboration/auditTrail'
|
||||||
import ShareModal from '@/components/editor/ShareModal'
|
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'
|
import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
// LocalStorage key prefix for draft saves
|
// LocalStorage key prefix for draft saves
|
||||||
const DRAFT_KEY_PREFIX = 'vnwrite-draft-'
|
const DRAFT_KEY_PREFIX = 'vnwrite-draft-'
|
||||||
|
|
||||||
// Debounce delay in ms
|
// Debounce delay for LocalStorage draft saves
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 1000
|
const AUTOSAVE_DEBOUNCE_MS = 5000
|
||||||
|
|
||||||
type ContextMenuState = {
|
type ContextMenuState = {
|
||||||
x: number
|
x: number
|
||||||
|
|
@ -302,6 +307,28 @@ function randomHexColor(): string {
|
||||||
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
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
|
// Compute auto-migration of existing free-text values to character/variable definitions
|
||||||
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||||
if (!shouldMigrate) {
|
if (!shouldMigrate) {
|
||||||
|
|
@ -500,17 +527,31 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
|
|
||||||
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
|
const [characters, setCharacters] = useState<Character[]>(migratedData.characters)
|
||||||
const [variables, setVariables] = useState<Variable[]>(migratedData.variables)
|
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 [showSettings, setShowSettings] = useState(false)
|
||||||
const [showShare, setShowShare] = useState(false)
|
const [showShare, setShowShare] = useState(false)
|
||||||
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
const [toastMessage, setToastMessage] = useState<string | null>(migratedData.toastMessage)
|
||||||
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
const [validationIssues, setValidationIssues] = useState<ValidationIssue[] | null>(null)
|
||||||
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
const [warningNodeIds, setWarningNodeIds] = useState<Set<string>>(new Set())
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
|
||||||
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
||||||
|
const [remoteCursors, setRemoteCursors] = useState<RemoteCursor[]>([])
|
||||||
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
const realtimeRef = useRef<RealtimeConnection | null>(null)
|
||||||
const crdtRef = useRef<CRDTManager | 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
|
// Initialize CRDT manager and connect to Supabase Realtime channel on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -518,25 +559,26 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
|
|
||||||
const crdtManager = new CRDTManager({
|
const crdtManager = new CRDTManager({
|
||||||
onNodesChange: (crdtNodes: FlowchartNode[]) => {
|
onNodesChange: (crdtNodes: FlowchartNode[]) => {
|
||||||
isRemoteUpdateRef.current = true
|
|
||||||
setNodes(toReactFlowNodes(crdtNodes))
|
setNodes(toReactFlowNodes(crdtNodes))
|
||||||
isRemoteUpdateRef.current = false
|
|
||||||
},
|
},
|
||||||
onEdgesChange: (crdtEdges: FlowchartEdge[]) => {
|
onEdgesChange: (crdtEdges: FlowchartEdge[]) => {
|
||||||
isRemoteUpdateRef.current = true
|
|
||||||
setEdges(toReactFlowEdges(crdtEdges))
|
setEdges(toReactFlowEdges(crdtEdges))
|
||||||
isRemoteUpdateRef.current = false
|
|
||||||
},
|
},
|
||||||
onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => {
|
onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => {
|
||||||
try {
|
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
|
await supabase
|
||||||
.from('projects')
|
.from('projects')
|
||||||
.update({
|
.update({
|
||||||
flowchart_data: {
|
flowchart_data: {
|
||||||
nodes: persistNodes,
|
nodes: persistNodes,
|
||||||
edges: persistEdges,
|
edges: persistEdges,
|
||||||
characters,
|
characters: charactersRef.current,
|
||||||
variables,
|
variables: variablesRef.current,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.eq('id', projectId)
|
.eq('id', projectId)
|
||||||
|
|
@ -550,9 +592,78 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
crdtManager.initializeFromData(migratedData.nodes, migratedData.edges)
|
crdtManager.initializeFromData(migratedData.nodes, migratedData.edges)
|
||||||
crdtRef.current = crdtManager
|
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, {
|
const connection = new RealtimeConnection(projectId, userId, userDisplayName, {
|
||||||
onConnectionStateChange: setConnectionState,
|
onConnectionStateChange: setConnectionState,
|
||||||
onPresenceSync: setPresenceUsers,
|
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) => {
|
onChannelSubscribed: (channel) => {
|
||||||
crdtManager.connectChannel(channel)
|
crdtManager.connectChannel(channel)
|
||||||
},
|
},
|
||||||
|
|
@ -561,14 +672,53 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
connection.connect()
|
connection.connect()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
// Release any held lock before disconnecting
|
||||||
|
if (localLockRef.current) {
|
||||||
|
connection.broadcastNodeLock(null)
|
||||||
|
localLockRef.current = null
|
||||||
|
}
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
realtimeRef.current = null
|
realtimeRef.current = null
|
||||||
crdtManager.destroy()
|
crdtManager.destroy()
|
||||||
crdtRef.current = null
|
crdtRef.current = null
|
||||||
|
auditRecorder.destroy()
|
||||||
|
auditRef.current = null
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [projectId, userId, userDisplayName])
|
}, [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)
|
// Sync local React Flow state changes to CRDT (skip remote-originated updates)
|
||||||
const nodesForCRDT = useMemo(() => {
|
const nodesForCRDT = useMemo(() => {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
|
|
@ -591,15 +741,223 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
}, [edges])
|
}, [edges])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRemoteUpdateRef.current) return
|
|
||||||
crdtRef.current?.updateNodes(nodesForCRDT)
|
crdtRef.current?.updateNodes(nodesForCRDT)
|
||||||
|
if (!isRevertingRef.current) {
|
||||||
|
auditRef.current?.recordNodeChanges(nodesForCRDT)
|
||||||
|
}
|
||||||
}, [nodesForCRDT])
|
}, [nodesForCRDT])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRemoteUpdateRef.current) return
|
|
||||||
crdtRef.current?.updateEdges(edgesForCRDT)
|
crdtRef.current?.updateEdges(edgesForCRDT)
|
||||||
|
if (!isRevertingRef.current) {
|
||||||
|
auditRef.current?.recordEdgeChanges(edgesForCRDT)
|
||||||
|
}
|
||||||
}, [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(
|
const handleAddCharacter = useCallback(
|
||||||
(name: string, color: string): string => {
|
(name: string, color: string): string => {
|
||||||
const id = nanoid()
|
const id = nanoid()
|
||||||
|
|
@ -621,8 +979,16 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
)
|
)
|
||||||
|
|
||||||
const editorContextValue = useMemo(
|
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(
|
const getCharacterUsageCount = useCallback(
|
||||||
|
|
@ -836,6 +1202,9 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
// Update last saved data ref to mark as not dirty
|
// Update last saved data ref to mark as not dirty
|
||||||
lastSavedDataRef.current = flowchartData
|
lastSavedDataRef.current = flowchartData
|
||||||
|
|
||||||
|
// Notify other connected clients to refresh from the database
|
||||||
|
realtimeRef.current?.broadcastStateRefresh()
|
||||||
|
|
||||||
setToast({ message: 'Project saved successfully', type: 'success' })
|
setToast({ message: 'Project saved successfully', type: 'success' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save project:', error)
|
console.error('Failed to save project:', error)
|
||||||
|
|
@ -1207,10 +1576,11 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onProjectSettings={() => setShowSettings(true)}
|
onProjectSettings={() => setShowSettings(true)}
|
||||||
onShare={() => setShowShare(true)}
|
onShare={() => setShowShare(true)}
|
||||||
|
onHistory={() => setShowHistory((v) => !v)}
|
||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
presenceUsers={presenceUsers}
|
presenceUsers={presenceUsers}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="relative flex-1" onMouseMove={handleMouseMove}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={styledNodes}
|
nodes={styledNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|
@ -1231,6 +1601,15 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
<Controls position="bottom-right" />
|
<Controls position="bottom-right" />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
<RemoteCursors cursors={remoteCursors} />
|
||||||
|
{showHistory && (
|
||||||
|
<ActivityHistorySidebar
|
||||||
|
projectId={projectId}
|
||||||
|
onClose={() => setShowHistory(false)}
|
||||||
|
onSelectEntity={handleHistorySelectEntity}
|
||||||
|
onRevert={handleRevertEntry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|
@ -1288,6 +1667,17 @@ function FlowchartEditorInner({ projectId, projectName, userId, userDisplayName,
|
||||||
onClose={() => setToastMessage(null)}
|
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>
|
</div>
|
||||||
</EditorProvider>
|
</EditorProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
|
|
||||||
// If not the owner, check if the user is a collaborator
|
// If not the owner, check if the user is a collaborator
|
||||||
if (!project) {
|
if (!project) {
|
||||||
// RLS on projects allows collaborators to SELECT shared projects
|
|
||||||
const { data: collab } = await supabase
|
const { data: collab } = await supabase
|
||||||
.from('project_collaborators')
|
.from('project_collaborators')
|
||||||
.select('id, role')
|
.select('id, role')
|
||||||
|
|
@ -49,58 +48,42 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (!collab) {
|
if (collab) {
|
||||||
notFound()
|
const { data: sharedProject } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, name, flowchart_data')
|
||||||
|
.eq('id', projectId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
project = sharedProject
|
||||||
|
isOwner = false
|
||||||
}
|
}
|
||||||
|
|
||||||
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 || {}
|
if (!project) {
|
||||||
const flowchartData: FlowchartData = {
|
return (
|
||||||
nodes: rawData.nodes || [],
|
<div className="flex h-screen flex-col">
|
||||||
edges: rawData.edges || [],
|
<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">
|
||||||
characters: rawData.characters || [],
|
<div className="flex items-center gap-4">
|
||||||
variables: rawData.variables || [],
|
<Link
|
||||||
}
|
href="/dashboard"
|
||||||
|
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
aria-label="Back to dashboard"
|
||||||
// the project was created before these features existed and may need auto-migration
|
|
||||||
const needsMigration = !rawData.characters && !rawData.variables
|
|
||||||
|
|
||||||
return (<>
|
|
||||||
<div className="flex h-screen flex-col">
|
|
||||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
|
||||||
aria-label="Back to dashboard"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-5 w-5"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
fillRule="evenodd"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
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"
|
className="h-5 w-5"
|
||||||
clipRule="evenodd"
|
viewBox="0 0 20 20"
|
||||||
/>
|
fill="currentColor"
|
||||||
</svg>
|
>
|
||||||
</Link>
|
<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>
|
||||||
|
</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">
|
||||||
|
|
@ -131,32 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<FlowchartEditor
|
|
||||||
projectId={project.id}
|
|
||||||
userId={user.id}
|
|
||||||
userDisplayName={userDisplayName}
|
|
||||||
isOwner={isOwner}
|
|
||||||
initialData={flowchartData}
|
|
||||||
needsMigration={needsMigration}
|
|
||||||
/>
|
|
||||||
</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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ type ToolbarProps = {
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
onProjectSettings: () => void
|
onProjectSettings: () => void
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
|
onHistory: () => void
|
||||||
connectionState?: ConnectionState
|
connectionState?: ConnectionState
|
||||||
presenceUsers?: PresenceUser[]
|
presenceUsers?: PresenceUser[]
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +45,7 @@ export default function Toolbar({
|
||||||
onImport,
|
onImport,
|
||||||
onProjectSettings,
|
onProjectSettings,
|
||||||
onShare,
|
onShare,
|
||||||
|
onHistory,
|
||||||
connectionState,
|
connectionState,
|
||||||
presenceUsers,
|
presenceUsers,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
|
|
@ -108,6 +110,12 @@ export default function Toolbar({
|
||||||
>
|
>
|
||||||
Share
|
Share
|
||||||
</button>
|
</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}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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'
|
||||||
|
|
||||||
type ChoiceOption = {
|
type ChoiceOption = {
|
||||||
|
|
@ -23,9 +24,12 @@ const MAX_OPTIONS = 6
|
||||||
|
|
||||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
const { variables } = useEditorContext() // Puxa as variáveis globais para validar condições
|
const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||||
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 ---
|
// --- Handlers de Atualização ---
|
||||||
|
|
||||||
const updatePrompt = useCallback(
|
const updatePrompt = useCallback(
|
||||||
|
|
@ -152,8 +156,26 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
[variables]
|
[variables]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
onNodeFocus(id)
|
||||||
|
}, [id, onNodeFocus])
|
||||||
|
|
||||||
return (
|
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" />
|
<Handle type="target" position={Position.Top} className="!bg-green-500" />
|
||||||
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import * as Y from 'yjs'
|
||||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||||
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
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'
|
const BROADCAST_EVENT = 'yjs-update'
|
||||||
|
|
||||||
export type CRDTCallbacks = {
|
export type CRDTCallbacks = {
|
||||||
|
|
@ -19,6 +19,7 @@ export class CRDTManager {
|
||||||
private callbacks: CRDTCallbacks
|
private callbacks: CRDTCallbacks
|
||||||
private persistTimer: ReturnType<typeof setTimeout> | null = null
|
private persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private isApplyingRemote = false
|
private isApplyingRemote = false
|
||||||
|
private isSuppressed = false // suppress broadcast/persist during init or refresh
|
||||||
private isDestroyed = false
|
private isDestroyed = false
|
||||||
|
|
||||||
constructor(callbacks: CRDTCallbacks) {
|
constructor(callbacks: CRDTCallbacks) {
|
||||||
|
|
@ -29,24 +30,25 @@ export class CRDTManager {
|
||||||
|
|
||||||
// Schedule persistence on local Yjs document changes
|
// Schedule persistence on local Yjs document changes
|
||||||
this.nodesMap.observe(() => {
|
this.nodesMap.observe(() => {
|
||||||
if (this.isApplyingRemote) return
|
if (this.isApplyingRemote || this.isSuppressed) return
|
||||||
this.schedulePersist()
|
this.schedulePersist()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.edgesMap.observe(() => {
|
this.edgesMap.observe(() => {
|
||||||
if (this.isApplyingRemote) return
|
if (this.isApplyingRemote || this.isSuppressed) return
|
||||||
this.schedulePersist()
|
this.schedulePersist()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Broadcast local updates to other clients
|
// Broadcast local updates to other clients
|
||||||
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
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)
|
this.broadcastUpdate(update)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initialize the Yjs document from database state */
|
/** Initialize the Yjs document from database state */
|
||||||
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||||
|
this.isSuppressed = true
|
||||||
this.doc.transact(() => {
|
this.doc.transact(() => {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
this.nodesMap.set(node.id, JSON.stringify(node))
|
this.nodesMap.set(node.id, JSON.stringify(node))
|
||||||
|
|
@ -55,29 +57,72 @@ export class CRDTManager {
|
||||||
this.edgesMap.set(edge.id, JSON.stringify(edge))
|
this.edgesMap.set(edge.id, JSON.stringify(edge))
|
||||||
})
|
})
|
||||||
}, 'init')
|
}, '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 {
|
connectChannel(channel: RealtimeChannel): void {
|
||||||
this.channel = channel
|
this.channel = channel
|
||||||
|
// Broadcast full state so other clients merge any updates they missed
|
||||||
|
this.broadcastFullState()
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for broadcast updates from other clients
|
/** Broadcast the full Yjs document state to sync all connected clients */
|
||||||
channel.on('broadcast', { event: BROADCAST_EVENT }, (payload) => {
|
broadcastFullState(): void {
|
||||||
if (this.isDestroyed) return
|
if (!this.channel || this.isDestroyed) return
|
||||||
const data = payload.payload as { update?: number[] } | undefined
|
const state = Y.encodeStateAsUpdate(this.doc)
|
||||||
if (data?.update) {
|
this.channel.send({
|
||||||
const update = new Uint8Array(data.update)
|
type: 'broadcast',
|
||||||
this.isApplyingRemote = true
|
event: BROADCAST_EVENT,
|
||||||
Y.applyUpdate(this.doc, update, 'remote')
|
payload: { update: Array.from(state) },
|
||||||
this.isApplyingRemote = false
|
|
||||||
// Notify React state of remote changes
|
|
||||||
this.notifyNodesChange()
|
|
||||||
this.notifyEdgesChange()
|
|
||||||
this.schedulePersist()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 */
|
/** Apply local node changes to the Yjs document */
|
||||||
updateNodes(nodes: FlowchartNode[]): void {
|
updateNodes(nodes: FlowchartNode[]): void {
|
||||||
if (this.isApplyingRemote) return
|
if (this.isApplyingRemote) return
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,58 @@ export type PresenceUser = {
|
||||||
displayName: 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 = {
|
type RealtimeCallbacks = {
|
||||||
onConnectionStateChange: (state: ConnectionState) => void
|
onConnectionStateChange: (state: ConnectionState) => void
|
||||||
onPresenceSync?: (users: PresenceUser[]) => void
|
onPresenceSync?: (users: PresenceUser[]) => void
|
||||||
|
onPresenceJoin?: (user: PresenceUser) => void
|
||||||
|
onPresenceLeave?: (user: PresenceUser) => void
|
||||||
onChannelSubscribed?: (channel: RealtimeChannel) => 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 HEARTBEAT_INTERVAL_MS = 30_000
|
||||||
const RECONNECT_BASE_DELAY_MS = 1000
|
const RECONNECT_BASE_DELAY_MS = 1000
|
||||||
const RECONNECT_MAX_DELAY_MS = 30_000
|
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 {
|
export class RealtimeConnection {
|
||||||
private channel: RealtimeChannel | null = null
|
private channel: RealtimeChannel | null = null
|
||||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | 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 reconnectAttempts = 0
|
||||||
private projectId: string
|
private projectId: string
|
||||||
private userId: string
|
private userId: string
|
||||||
private displayName: string
|
private displayName: string
|
||||||
private callbacks: RealtimeCallbacks
|
private callbacks: RealtimeCallbacks
|
||||||
private isDestroyed = false
|
private isDestroyed = false
|
||||||
|
private isPaused = false
|
||||||
|
private lastActivityTime = Date.now()
|
||||||
private supabase = createClient()
|
private supabase = createClient()
|
||||||
|
|
||||||
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
||||||
|
|
@ -39,7 +71,36 @@ export class RealtimeConnection {
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.isDestroyed) return
|
if (this.isDestroyed) return
|
||||||
|
this.isPaused = false
|
||||||
|
this.lastActivityTime = Date.now()
|
||||||
this.callbacks.onConnectionStateChange('connecting')
|
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}`, {
|
this.channel = this.supabase.channel(`project:${this.projectId}`, {
|
||||||
config: { presence: { key: this.userId } },
|
config: { presence: { key: this.userId } },
|
||||||
|
|
@ -63,8 +124,62 @@ export class RealtimeConnection {
|
||||||
this.callbacks.onPresenceSync?.(users)
|
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) => {
|
.subscribe(async (status) => {
|
||||||
if (this.isDestroyed) return
|
if (this.isDestroyed) return
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
if (status === 'SUBSCRIBED') {
|
if (status === 'SUBSCRIBED') {
|
||||||
this.reconnectAttempts = 0
|
this.reconnectAttempts = 0
|
||||||
|
|
@ -84,15 +199,24 @@ export class RealtimeConnection {
|
||||||
this.callbacks.onConnectionStateChange('reconnecting')
|
this.callbacks.onConnectionStateChange('reconnecting')
|
||||||
this.scheduleReconnect()
|
this.scheduleReconnect()
|
||||||
} else if (status === 'CLOSED') {
|
} else if (status === 'CLOSED') {
|
||||||
this.callbacks.onConnectionStateChange('disconnected')
|
if (!this.isPaused) {
|
||||||
|
// Unexpected close - attempt to reconnect
|
||||||
|
this.callbacks.onConnectionStateChange('reconnecting')
|
||||||
|
this.scheduleReconnect()
|
||||||
|
} else {
|
||||||
|
this.callbacks.onConnectionStateChange('disconnected')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
this.isDestroyed = true
|
this.isDestroyed = true
|
||||||
|
this.isPaused = false
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
this.clearReconnectTimer()
|
this.clearReconnectTimer()
|
||||||
|
this.clearInactivityTimer()
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
if (this.channel) {
|
if (this.channel) {
|
||||||
await this.deleteSession()
|
await this.deleteSession()
|
||||||
|
|
@ -103,10 +227,133 @@ export class RealtimeConnection {
|
||||||
this.callbacks.onConnectionStateChange('disconnected')
|
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 {
|
getChannel(): RealtimeChannel | null {
|
||||||
return this.channel
|
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> {
|
private async createSession(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.supabase.from('collaboration_sessions').upsert(
|
await this.supabase.from('collaboration_sessions').upsert(
|
||||||
|
|
@ -139,19 +386,44 @@ export class RealtimeConnection {
|
||||||
|
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
|
let consecutiveFailures = 0
|
||||||
this.heartbeatTimer = setInterval(async () => {
|
this.heartbeatTimer = setInterval(async () => {
|
||||||
if (this.isDestroyed) {
|
if (this.isDestroyed || this.isPaused) {
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
return
|
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 {
|
try {
|
||||||
await this.supabase
|
await this.supabase
|
||||||
.from('collaboration_sessions')
|
.from('collaboration_sessions')
|
||||||
.update({ last_heartbeat: new Date().toISOString() })
|
.update({ last_heartbeat: new Date().toISOString() })
|
||||||
.eq('project_id', this.projectId)
|
.eq('project_id', this.projectId)
|
||||||
.eq('user_id', this.userId)
|
.eq('user_id', this.userId)
|
||||||
|
consecutiveFailures = 0
|
||||||
} catch {
|
} 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)
|
}, HEARTBEAT_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
@ -192,4 +464,28 @@ export class RealtimeConnection {
|
||||||
this.reconnectTimer = null
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue