Compare commits
44
Commits
Generated
+50
-1
@@ -14,7 +14,8 @@
|
||||
"next": "16.1.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"reactflow": "^11.11.4"
|
||||
"reactflow": "^11.11.4",
|
||||
"yjs": "^13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -4975,6 +4976,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
@@ -5130,6 +5141,27 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.117",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
|
||||
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -7186,6 +7218,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.29",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
|
||||
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"next": "16.1.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"reactflow": "^11.11.4"
|
||||
"reactflow": "^11.11.4",
|
||||
"yjs": "^13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 12,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-058, US-059, US-060, US-061"
|
||||
},
|
||||
{
|
||||
@@ -232,7 +232,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 13,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -251,7 +251,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 14,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-043"
|
||||
},
|
||||
{
|
||||
@@ -269,7 +269,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 15,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-043"
|
||||
},
|
||||
{
|
||||
@@ -287,7 +287,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 16,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045"
|
||||
},
|
||||
{
|
||||
@@ -306,7 +306,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 17,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045"
|
||||
},
|
||||
{
|
||||
@@ -324,7 +324,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 18,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045, US-046"
|
||||
},
|
||||
{
|
||||
@@ -341,7 +341,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 19,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045, US-046"
|
||||
},
|
||||
{
|
||||
@@ -359,7 +359,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 20,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045, US-048"
|
||||
},
|
||||
{
|
||||
@@ -376,7 +376,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 21,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-043, US-048"
|
||||
},
|
||||
{
|
||||
@@ -394,7 +394,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 22,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-051"
|
||||
},
|
||||
{
|
||||
@@ -414,7 +414,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 23,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-052, US-048"
|
||||
}
|
||||
]
|
||||
|
||||
+126
@@ -43,6 +43,33 @@
|
||||
- `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions.
|
||||
- React Compiler lint forbids `setState` in effects and reading `useRef().current` during render. Use `useState(() => computeValue())` lazy initializer pattern for one-time initialization logic.
|
||||
- For detecting legacy data shape (pre-migration), pass a flag from the server component (page.tsx) to the client component, since only the server reads raw DB data.
|
||||
- Collaboration tables: `project_collaborators` (roles), `collaboration_sessions` (presence), `audit_trail` (history) — all with RLS scoped by project ownership or collaborator membership
|
||||
- RLS pattern for shared resources: check `projects.user_id = auth.uid()` OR `project_collaborators.user_id = auth.uid()` to cover both owners and collaborators
|
||||
- `RealtimeConnection` class at `src/lib/collaboration/realtime.ts` manages Supabase Realtime channel lifecycle (connect, heartbeat, reconnect, disconnect). Instantiate with (projectId, userId, callbacks).
|
||||
- FlowchartEditor receives `userId` prop from page.tsx server component for collaboration features
|
||||
- Toolbar accepts optional `connectionState` prop to show green/yellow/red connection indicator
|
||||
- `collaboration_sessions` table has UNIQUE(project_id, user_id) constraint to support upsert-based session management
|
||||
- Server actions for project-specific operations go in `src/app/editor/[projectId]/actions.ts` — use `'use server'` directive and return `{ success: boolean; error?: string }` pattern
|
||||
- Editor page.tsx supports both owner and collaborator access: first checks ownership, then falls back to `project_collaborators` lookup. Pass `isOwner` prop to client component.
|
||||
- `ShareModal` at `src/components/editor/ShareModal.tsx` manages collaborator invites/roles/removal via server actions. Only owners see invite form.
|
||||
- Dashboard shared projects use Supabase join query: `project_collaborators.select('role, projects(id, name, updated_at)')` to fetch projects shared with the user
|
||||
- `ProjectCard` supports optional `shared` and `sharedRole` props — when `shared=true`, hide edit/delete buttons and show role badge instead
|
||||
- `PresenceAvatars` at `src/components/editor/PresenceAvatars.tsx` renders connected collaborator avatars. Receives `PresenceUser[]` from `RealtimeConnection.onPresenceSync`.
|
||||
- `RealtimeConnection` constructor takes `(projectId, userId, displayName, callbacks)` — `displayName` is broadcast via Supabase Realtime presence tracking
|
||||
- User color for presence is derived from a hash of their userId, ensuring consistency across sessions. Use `getUserColor(userId)` pattern from PresenceAvatars.
|
||||
- `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map<string> for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates.
|
||||
- CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops.
|
||||
- For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive.
|
||||
- For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed
|
||||
- `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref.
|
||||
- Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events.
|
||||
- `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot.
|
||||
- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user.
|
||||
- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release.
|
||||
- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor).
|
||||
- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. Exports `AuditEntry` type for consumers.
|
||||
- To prevent double audit recording when programmatically changing nodes/edges (e.g., revert), set a ref guard (`isRevertingRef`) before `setNodes`/`setEdges` and clear it with `setTimeout(() => ..., 0)`. Check the guard in the CRDT sync effects before calling `auditRef.current.recordNodeChanges()`.
|
||||
- For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect.
|
||||
|
||||
---
|
||||
|
||||
@@ -379,3 +406,102 @@
|
||||
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
|
||||
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-047
|
||||
- What was implemented: Live cursor positions on canvas showing collaborators' mouse positions in real-time
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/realtime.ts` - Added `CursorPosition`, `RemoteCursor` types, `onCursorUpdate` callback to `RealtimeCallbacks`, broadcast listener for 'cursor' events, and `broadcastCursor()` method
|
||||
- `src/components/editor/RemoteCursors.tsx` - New component: renders colored arrow cursors with user name labels, smooth position interpolation via CSS transition, 5-second fade-out for inactive cursors, flow-to-screen coordinate transformation using React Flow viewport
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `remoteCursors` state, `cursorThrottleRef` for 50ms throttling, `handleMouseMove` that converts screen→flow coordinates and broadcasts via RealtimeConnection, cleanup of cursors for disconnected users, rendering of RemoteCursors overlay
|
||||
- `src/app/editor/[projectId]/page.tsx` - Fixed broken JSX structure (malformed HTML nesting and dead code after return)
|
||||
- **Learnings for future iterations:**
|
||||
- `screenToFlowPosition` from `useReactFlow()` converts screen-relative mouse coordinates to flow coordinates; for the reverse (rendering cursors), multiply by viewport.zoom and add viewport offset
|
||||
- Cursor broadcast uses Supabase Realtime broadcast (not presence) for efficiency: `channel.send({ type: 'broadcast', event: 'cursor', payload })`. Broadcast is fire-and-forget (no persistence).
|
||||
- React Compiler lint treats `Date.now()` as an impure function call — use `useState(() => Date.now())` lazy initializer pattern instead of `useState(Date.now())`
|
||||
- Throttling mouse events uses a ref storing the last broadcast timestamp (`cursorThrottleRef`), checked at the start of the handler before computing flow position
|
||||
- Remote cursors are removed when their user disconnects (filtered by `presenceUsers` list changes)
|
||||
- CSS `transition: transform 80ms linear` provides smooth interpolation between position updates without needing requestAnimationFrame
|
||||
- The `page.tsx` had a corrupted structure with unclosed tags and dead code — likely from a failed merge. Fixed by restructuring the error/not-found case into a proper early return
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-050
|
||||
- What was implemented: Join/leave toast notifications when collaborators connect or disconnect from the editing session
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/realtime.ts` - Added `onPresenceJoin` and `onPresenceLeave` callbacks to `RealtimeCallbacks` type; added Supabase Realtime `presence.join` and `presence.leave` event listeners that filter out own user and invoke callbacks
|
||||
- `src/components/editor/CollaborationToast.tsx` - New component: renders a compact toast notification with user's presence color dot, "[Name] joined" or "[Name] left" message, auto-dismisses after 3 seconds
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `getUserColor` helper (same hash logic as PresenceAvatars), `collaborationNotifications` state, `onPresenceJoin`/`onPresenceLeave` handlers on RealtimeConnection, `handleDismissNotification` callback, and rendering of CollaborationToast list in bottom-left corner
|
||||
- **Learnings for future iterations:**
|
||||
- Supabase Realtime presence has three event types: `sync` (full state), `join` (new arrivals), and `leave` (departures). Each provides an array of presences (`newPresences`/`leftPresences`). Use all three for different purposes.
|
||||
- The `join` event fires for each newly tracked presence. It includes the presence payload (userId, displayName) that was passed to `channel.track()`.
|
||||
- Collaboration notifications are positioned `bottom-left` (`left-4`) to avoid overlapping with the existing Toast component which is `bottom-right` (`right-4`).
|
||||
- The `getUserColor` function is duplicated from PresenceAvatars to avoid circular imports. Both use the same hash-to-color-index algorithm with the same RANDOM_COLORS palette for consistency.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-049
|
||||
- What was implemented: Node editing lock indicators that show when another collaborator is editing a node
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/realtime.ts` - Added `NodeLock` type, `onNodeLockUpdate` callback to `RealtimeCallbacks`, `node-lock` broadcast listener, and `broadcastNodeLock()` method
|
||||
- `src/components/editor/NodeLockIndicator.tsx` - New component: renders a colored border and name label overlay on locked nodes
|
||||
- `src/components/editor/EditorContext.tsx` - Extended context with `NodeLockInfo` type, `nodeLocks` (Map), `onNodeFocus`, and `onNodeBlur` callbacks
|
||||
- `src/components/editor/nodes/DialogueNode.tsx` - Added lock detection, `NodeLockIndicator` rendering, "Being edited by [name]" overlay, `onFocus`/`onBlur` handlers
|
||||
- `src/components/editor/nodes/VariableNode.tsx` - Same lock indicator pattern as DialogueNode
|
||||
- `src/components/editor/nodes/ChoiceNode.tsx` - Same lock indicator pattern as DialogueNode
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `nodeLocks` state (Map<string, NodeLockInfo>), `localLockRef` for tracking own lock, `handleNodeFocus`/`handleNodeBlur` callbacks, `onNodeLockUpdate` handler in RealtimeConnection, lock expiry timer (60s check every 5s), lock cleanup on user leave and component unmount, extended `editorContextValue` with lock state
|
||||
- **Learnings for future iterations:**
|
||||
- Node lock uses Supabase Realtime broadcast (like cursors) — ephemeral, not persisted to DB. Event name: `node-lock`.
|
||||
- Lock broadcasting uses `nodeId: string | null` pattern: non-null to acquire lock, `null` to release. The receiving side maps userId to their current lock.
|
||||
- Lock expiry uses a 60-second timeout checked every 5 seconds via `setInterval`. The `lockedAt` timestamp is broadcast with the lock payload.
|
||||
- Each node component accesses lock state via `EditorContext` (`nodeLocks` Map). The `NodeLockInfo` type extends `NodeLock` with a `color` field derived from `getUserColor()`.
|
||||
- `onFocus`/`onBlur` on the node container div fires when any child input gains/loses focus (focus event bubbles), which naturally maps to "user is editing this node".
|
||||
- Lock release on disconnect: broadcast `null` lock before calling `connection.disconnect()` in the cleanup return of the mount effect.
|
||||
- Locks from disconnected users are cleaned up in the same effect that removes cursors (filtered by `presenceUsers` list).
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-051
|
||||
- What was implemented: Audit trail recording that writes all node/edge add/update/delete operations to the `audit_trail` table with debouncing and fire-and-forget semantics
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/auditTrail.ts` - New `AuditTrailRecorder` class: tracks previous node/edge state, diffs against current state to detect add/update/delete operations, debounces writes per entity (1 second), merges rapid sequential actions (e.g., add+update=add, add+delete=no-op), fire-and-forget Supabase inserts with error logging, flush on destroy
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `auditRef` (AuditTrailRecorder), initialized in mount effect alongside CRDT manager, records node/edge changes in the CRDT sync effects (only for local changes, skipped for remote updates), destroyed on unmount
|
||||
- **Learnings for future iterations:**
|
||||
- The audit recorder piggybacks on the same CRDT sync effects that already compute `nodesForCRDT`/`edgesForCRDT` — this avoids duplicating the React Flow → FlowchartNode/Edge conversion.
|
||||
- The `isRemoteUpdateRef` guard in the sync effects ensures audit entries are only created for local user actions, not for changes received from other collaborators (those users' own recorders will handle their audit entries).
|
||||
- Debouncing per entity (1 second) prevents rapid edits (e.g., typing in a text field) from flooding the audit table. The merge logic handles transient states (add+delete within 1s = skip).
|
||||
- The `destroy()` method flushes pending entries synchronously on unmount, ensuring in-flight edits aren't lost when navigating away.
|
||||
- Supabase `.insert().then()` pattern provides fire-and-forget writes with error logging — the async operation doesn't block the editing flow.
|
||||
- No browser testing needed — this is a developer/infrastructure story with no UI changes.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-052
|
||||
- What was implemented: Activity history sidebar that displays audit trail entries, grouped by time period, with entity selection on click
|
||||
- Files changed:
|
||||
- `src/components/editor/ActivityHistorySidebar.tsx` - New component: right sidebar panel showing chronological audit trail entries, grouped by Today/Yesterday/Earlier, with user color accents, entity descriptions, paginated loading (20 per page), and click-to-select entity on canvas
|
||||
- `src/components/editor/Toolbar.tsx` - Added `onHistory` prop and "History" button in the right toolbar section
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `showHistory` state, `handleHistorySelectEntity` callback (selects nodes/edges on canvas), `ActivityHistorySidebar` import and rendering inside the canvas area, `onHistory` toggle prop on Toolbar
|
||||
- **Learnings for future iterations:**
|
||||
- React Compiler lint (`react-hooks/set-state-in-effect`) treats any function that calls setState as problematic when invoked inside useEffect — even if the setState is in an async `.then()` callback. To avoid this, extract data fetching into a pure function that returns a result object, then handle setState only in the `.then()` callback after checking a mounted/aborted guard.
|
||||
- For right sidebar panels overlaying the canvas, use `absolute right-0 top-0 z-40 h-full w-80` inside the `relative flex-1` canvas container. This keeps the sidebar within the canvas area without affecting the toolbar.
|
||||
- The `audit_trail` table has an index on `(project_id, created_at DESC)` which makes paginated queries efficient. Use `.range(offset, offset + PAGE_SIZE - 1)` for Supabase pagination.
|
||||
- Entity descriptions are derived from `new_state` (for adds/updates) or `previous_state` (for deletes). The state contains the full node/edge data including `type`, `data.speaker`, `data.question`, `data.variableName`.
|
||||
- Deleted entities (`action_type.endsWith('_delete')`) cannot be selected on canvas since they no longer exist — render those entries as disabled (no click handler, reduced opacity).
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-053
|
||||
- What was implemented: Revert changes from audit trail — each history entry has a Revert button, confirmation dialog with before/after preview, and revert logic that reverses node/edge add/update/delete operations
|
||||
- Files changed:
|
||||
- `src/components/editor/RevertConfirmDialog.tsx` - New component: confirmation dialog showing action description, before/after state previews, with Cancel and Revert buttons
|
||||
- `src/components/editor/ActivityHistorySidebar.tsx` - Added Revert button (shows on hover per entry), `revertEntry` state, `RevertConfirmDialog` rendering, exported `AuditEntry` type, added `onRevert` prop
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `handleRevertEntry` callback implementing all revert cases (node add→delete, node update→restore, node delete→recreate, same for edges), `isRevertingRef` to prevent double audit recording, `getRevertActionType` helper, explicit audit trail write for the revert itself, toast notification on success
|
||||
- **Learnings for future iterations:**
|
||||
- Reverting uses the same `setNodes`/`setEdges` state setters, so CRDT sync happens automatically through the existing effects that watch `nodesForCRDT`/`edgesForCRDT` — no explicit CRDT call needed.
|
||||
- To prevent double audit recording (once from the sync effect, once from the explicit revert write), use an `isRevertingRef` guard set synchronously before `setNodes`/`setEdges` and cleared with `setTimeout(() => ..., 0)` after React processes the state updates.
|
||||
- The revert audit entry uses inverse action types: reverting `node_add` records `node_delete`, reverting `node_delete` records `node_add`, `node_update` stays `node_update`.
|
||||
- The `previous_state` and `new_state` are swapped for the revert audit entry: what was `new_state` (the current state being reverted) becomes `previous_state`, and what was `previous_state` (the state being restored) becomes `new_state`.
|
||||
- Reverting a `node_add` also removes connected edges to prevent dangling edge references.
|
||||
- The RevertConfirmDialog uses `z-[70]` to layer above the ActivityHistorySidebar (`z-40`).
|
||||
- The Revert button uses CSS `group-hover:inline-block` pattern to appear only on entry hover, keeping the UI clean.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import NewProjectButton from '@/components/NewProjectButton'
|
||||
import ProjectList from '@/components/ProjectList'
|
||||
import ProjectCard from '@/components/ProjectCard'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
@@ -19,6 +20,21 @@ export default async function DashboardPage() {
|
||||
.eq('user_id', user.id)
|
||||
.order('updated_at', { ascending: false })
|
||||
|
||||
// Fetch shared projects (projects where this user is a collaborator)
|
||||
const { data: collaborations } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('role, projects(id, name, updated_at)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const sharedProjects = (collaborations || [])
|
||||
.filter((c) => c.projects)
|
||||
.map((c) => ({
|
||||
...(c.projects as unknown as { id: string; name: string; updated_at: string }),
|
||||
shared: true,
|
||||
role: c.role,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
@@ -44,6 +60,26 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<ProjectList initialProjects={projects || []} />
|
||||
|
||||
{sharedProjects.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<h2 className="mb-4 text-xl font-bold text-zinc-900 dark:text-zinc-50">
|
||||
Shared with me
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sharedProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
updatedAt={project.updated_at}
|
||||
shared
|
||||
sharedRole={project.role}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export type Collaborator = {
|
||||
id: string
|
||||
user_id: string
|
||||
role: 'owner' | 'editor' | 'viewer'
|
||||
invited_at: string
|
||||
accepted_at: string | null
|
||||
display_name: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export async function getCollaborators(
|
||||
projectId: string
|
||||
): Promise<{ success: boolean; data?: Collaborator[]; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' }
|
||||
}
|
||||
|
||||
const isOwner = project.user_id === user.id
|
||||
|
||||
// Check if user is a collaborator
|
||||
if (!isOwner) {
|
||||
const { data: collab } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id')
|
||||
.eq('project_id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!collab) {
|
||||
return { success: false, error: 'Access denied' }
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch collaborators with profile info
|
||||
const { data: collaborators, error } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id, user_id, role, invited_at, accepted_at, profiles(display_name, email)')
|
||||
.eq('project_id', projectId)
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
const result: Collaborator[] = (collaborators || []).map((c) => {
|
||||
const profile = c.profiles as unknown as { display_name: string | null; email: string | null } | null
|
||||
return {
|
||||
id: c.id,
|
||||
user_id: c.user_id,
|
||||
role: c.role as 'owner' | 'editor' | 'viewer',
|
||||
invited_at: c.invited_at,
|
||||
accepted_at: c.accepted_at,
|
||||
display_name: profile?.display_name || null,
|
||||
email: profile?.email || null,
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, data: result }
|
||||
}
|
||||
|
||||
export async function inviteCollaborator(
|
||||
projectId: string,
|
||||
email: string,
|
||||
role: 'editor' | 'viewer'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Only the project owner can invite collaborators' }
|
||||
}
|
||||
|
||||
// Find the user by email in profiles
|
||||
const { data: targetProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.eq('email', email)
|
||||
.single()
|
||||
|
||||
if (!targetProfile) {
|
||||
return { success: false, error: 'No user found with that email address' }
|
||||
}
|
||||
|
||||
// Cannot invite yourself
|
||||
if (targetProfile.id === user.id) {
|
||||
return { success: false, error: 'You cannot invite yourself' }
|
||||
}
|
||||
|
||||
// Check if already a collaborator
|
||||
const { data: existing } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id')
|
||||
.eq('project_id', projectId)
|
||||
.eq('user_id', targetProfile.id)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: 'This user is already a collaborator' }
|
||||
}
|
||||
|
||||
// Insert collaborator
|
||||
const { error: insertError } = await supabase
|
||||
.from('project_collaborators')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
user_id: targetProfile.id,
|
||||
role,
|
||||
invited_at: new Date().toISOString(),
|
||||
accepted_at: new Date().toISOString(), // Auto-accept for now
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
return { success: false, error: insertError.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updateCollaboratorRole(
|
||||
projectId: string,
|
||||
collaboratorId: string,
|
||||
role: 'editor' | 'viewer'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Only the project owner can change roles' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('project_collaborators')
|
||||
.update({ role })
|
||||
.eq('id', collaboratorId)
|
||||
.eq('project_id', projectId)
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function removeCollaborator(
|
||||
projectId: string,
|
||||
collaboratorId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Only the project owner can remove collaborators' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('project_collaborators')
|
||||
.delete()
|
||||
.eq('id', collaboratorId)
|
||||
.eq('project_id', projectId)
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -19,51 +19,71 @@ export default async function EditorPage({ params }: PageProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { data: project, error } = await supabase
|
||||
// Fetch user's display name for presence
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('display_name')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const userDisplayName = profile?.display_name || user.email || 'Anonymous'
|
||||
|
||||
// Try to load as owner first
|
||||
const { data: ownedProject } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !project) {
|
||||
notFound()
|
||||
let project = ownedProject
|
||||
let isOwner = true
|
||||
|
||||
// If not the owner, check if the user is a collaborator
|
||||
if (!project) {
|
||||
const { data: collab } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id, role')
|
||||
.eq('project_id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (collab) {
|
||||
const { data: sharedProject } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
project = sharedProject
|
||||
isOwner = false
|
||||
}
|
||||
}
|
||||
|
||||
const rawData = project.flowchart_data || {}
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: rawData.nodes || [],
|
||||
edges: rawData.edges || [],
|
||||
characters: rawData.characters || [],
|
||||
variables: rawData.variables || [],
|
||||
}
|
||||
|
||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
||||
// the project was created before these features existed and may need auto-migration
|
||||
const needsMigration = !rawData.characters && !rawData.variables
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
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"
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-4">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
@@ -94,28 +114,31 @@ export default async function EditorPage({ params }: PageProps) {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1">
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
initialData={flowchartData}
|
||||
needsMigration={needsMigration}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const flowchartData = (project.flowchart_data || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}) as FlowchartData
|
||||
const rawData = project.flowchart_data || {}
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: rawData.nodes || [],
|
||||
edges: rawData.edges || [],
|
||||
characters: rawData.characters || [],
|
||||
variables: rawData.variables || [],
|
||||
}
|
||||
|
||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
||||
// the project was created before these features existed and may need auto-migration
|
||||
const needsMigration = !rawData.characters && !rawData.variables
|
||||
|
||||
return (
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
userId={user.id}
|
||||
userDisplayName={userDisplayName}
|
||||
isOwner={isOwner}
|
||||
initialData={flowchartData}
|
||||
needsMigration={needsMigration}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,3 +24,16 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Export validation warning highlighting for React Flow nodes */
|
||||
.react-flow__node.export-warning-node {
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
animation: pulse-warning 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% { outline-color: #f97316; }
|
||||
50% { outline-color: #fb923c; }
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ interface ProjectCardProps {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
onDelete: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
onRename?: (id: string, newName: string) => void
|
||||
shared?: boolean
|
||||
sharedRole?: string
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
@@ -29,6 +31,8 @@ export default function ProjectCard({
|
||||
updatedAt,
|
||||
onDelete,
|
||||
onRename,
|
||||
shared,
|
||||
sharedRole,
|
||||
}: ProjectCardProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -62,7 +66,7 @@ export default function ProjectCard({
|
||||
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
onDelete(id)
|
||||
onDelete?.(id)
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
@@ -106,7 +110,7 @@ export default function ProjectCard({
|
||||
|
||||
setIsRenaming(false)
|
||||
setShowRenameDialog(false)
|
||||
onRename(id, newName.trim())
|
||||
onRename?.(id, newName.trim())
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
@@ -122,46 +126,55 @@ export default function ProjectCard({
|
||||
onClick={handleCardClick}
|
||||
className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600"
|
||||
>
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={handleRenameClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
|
||||
title="Rename project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
{!shared && onRename && onDelete && (
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={handleRenameClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
|
||||
title="Rename project"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
title="Delete project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
title="Delete project"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{shared && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{sharedRole === 'editor' ? 'Editor' : 'Viewer'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="pr-8 text-lg font-semibold text-zinc-900 group-hover:text-blue-600 dark:text-zinc-50 dark:group-hover:text-blue-400">
|
||||
{name}
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import RevertConfirmDialog from './RevertConfirmDialog'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export type AuditEntry = {
|
||||
id: string
|
||||
project_id: string
|
||||
user_id: string
|
||||
action_type: string
|
||||
entity_id: string
|
||||
previous_state: Record<string, unknown> | null
|
||||
new_state: Record<string, unknown> | null
|
||||
created_at: string
|
||||
user_display_name?: string
|
||||
}
|
||||
|
||||
type ActivityHistorySidebarProps = {
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSelectEntity: (entityId: string, actionType: string) => void
|
||||
onRevert: (entry: AuditEntry) => void
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
node_add: 'Added node',
|
||||
node_update: 'Updated node',
|
||||
node_delete: 'Deleted node',
|
||||
edge_add: 'Added edge',
|
||||
edge_update: 'Updated edge',
|
||||
edge_delete: 'Deleted edge',
|
||||
}
|
||||
|
||||
const ACTION_ICONS: Record<string, string> = {
|
||||
node_add: '+',
|
||||
node_update: '~',
|
||||
node_delete: '-',
|
||||
edge_add: '+',
|
||||
edge_update: '~',
|
||||
edge_delete: '-',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
node_add: 'text-green-500',
|
||||
node_update: 'text-blue-500',
|
||||
node_delete: 'text-red-500',
|
||||
edge_add: 'text-green-500',
|
||||
edge_update: 'text-blue-500',
|
||||
edge_delete: 'text-red-500',
|
||||
}
|
||||
|
||||
// Same hash logic as PresenceAvatars and FlowchartEditor
|
||||
const PRESENCE_COLORS = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
|
||||
function getUserColor(userId: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||
}
|
||||
return PRESENCE_COLORS[Math.abs(hash) % PRESENCE_COLORS.length]
|
||||
}
|
||||
|
||||
function getEntityDescription(entry: AuditEntry): string {
|
||||
const state = entry.new_state || entry.previous_state
|
||||
if (!state) return entry.entity_id.slice(0, 8)
|
||||
|
||||
if (entry.action_type.startsWith('node_')) {
|
||||
const type = (state as Record<string, unknown>).type as string | undefined
|
||||
const data = (state as Record<string, unknown>).data as Record<string, unknown> | undefined
|
||||
if (type === 'dialogue' && data) {
|
||||
const speaker = (data.speaker as string) || (data.characterId as string) || ''
|
||||
return speaker ? `Dialogue (${speaker})` : 'Dialogue node'
|
||||
}
|
||||
if (type === 'choice' && data) {
|
||||
const question = (data.question as string) || ''
|
||||
return question ? `Choice: "${question.slice(0, 20)}${question.length > 20 ? '…' : ''}"` : 'Choice node'
|
||||
}
|
||||
if (type === 'variable' && data) {
|
||||
const name = (data.variableName as string) || ''
|
||||
return name ? `Variable: ${name}` : 'Variable node'
|
||||
}
|
||||
return `${type || 'Unknown'} node`
|
||||
}
|
||||
|
||||
// Edge entries
|
||||
return 'Connection'
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getTimePeriod(dateStr: string): 'Today' | 'Yesterday' | 'Earlier' {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (date >= today) return 'Today'
|
||||
if (date >= yesterday) return 'Yesterday'
|
||||
return 'Earlier'
|
||||
}
|
||||
|
||||
type GroupedEntries = {
|
||||
period: 'Today' | 'Yesterday' | 'Earlier'
|
||||
entries: AuditEntry[]
|
||||
}
|
||||
|
||||
function groupByPeriod(entries: AuditEntry[]): GroupedEntries[] {
|
||||
const groups: Record<string, AuditEntry[]> = {}
|
||||
const order: ('Today' | 'Yesterday' | 'Earlier')[] = ['Today', 'Yesterday', 'Earlier']
|
||||
|
||||
for (const entry of entries) {
|
||||
const period = getTimePeriod(entry.created_at)
|
||||
if (!groups[period]) groups[period] = []
|
||||
groups[period].push(entry)
|
||||
}
|
||||
|
||||
return order
|
||||
.filter((p) => groups[p] && groups[p].length > 0)
|
||||
.map((p) => ({ period: p, entries: groups[p] }))
|
||||
}
|
||||
|
||||
export default function ActivityHistorySidebar({
|
||||
projectId,
|
||||
onClose,
|
||||
onSelectEntity,
|
||||
onRevert,
|
||||
}: ActivityHistorySidebarProps) {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [revertEntry, setRevertEntry] = useState<AuditEntry | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const fetchEntriesRaw = useCallback(async (offset: number): Promise<{ entries: AuditEntry[]; hasMore: boolean; error?: string }> => {
|
||||
const supabase = createClient()
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('audit_trail')
|
||||
.select('id, project_id, user_id, action_type, entity_id, previous_state, new_state, created_at')
|
||||
.eq('project_id', projectId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + PAGE_SIZE - 1)
|
||||
|
||||
if (fetchError) {
|
||||
return { entries: [], hasMore: false, error: fetchError.message }
|
||||
}
|
||||
|
||||
const moreAvailable = !!data && data.length >= PAGE_SIZE
|
||||
|
||||
// Fetch user display names for the entries
|
||||
if (data && data.length > 0) {
|
||||
const userIds = [...new Set(data.map((e) => e.user_id))]
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, display_name')
|
||||
.in('id', userIds)
|
||||
|
||||
const nameMap = new Map<string, string>()
|
||||
if (profiles) {
|
||||
for (const p of profiles) {
|
||||
nameMap.set(p.id, p.display_name || 'Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries: data.map((e) => ({
|
||||
...e,
|
||||
user_display_name: nameMap.get(e.user_id) || 'Unknown',
|
||||
})),
|
||||
hasMore: moreAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: data || [], hasMore: moreAvailable }
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const controller = new AbortController()
|
||||
fetchEntriesRaw(0).then((result) => {
|
||||
if (!controller.signal.aborted && mountedRef.current) {
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setEntries(result.entries)
|
||||
setHasMore(result.hasMore)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
controller.abort()
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [fetchEntriesRaw])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
setLoadingMore(true)
|
||||
const result = await fetchEntriesRaw(entries.length)
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setEntries((prev) => [...prev, ...result.entries])
|
||||
setHasMore(result.hasMore)
|
||||
}
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const grouped = groupByPeriod(entries)
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-0 z-40 flex h-full w-80 flex-col border-l border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Activity History
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
title="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="h-5 w-5 animate-spin text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 text-sm text-red-500 dark:text-red-400">
|
||||
Failed to load history: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && grouped.map((group) => (
|
||||
<div key={group.period}>
|
||||
<div className="sticky top-0 bg-zinc-50 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-zinc-500 dark:bg-zinc-850 dark:text-zinc-400" style={{ backgroundColor: 'inherit' }}>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800">
|
||||
{group.period}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-100 dark:divide-zinc-700/50">
|
||||
{group.entries.map((entry) => {
|
||||
const userColor = getUserColor(entry.user_id)
|
||||
const isDeleted = entry.action_type.endsWith('_delete')
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`group w-full px-4 py-2.5 text-left transition-colors ${
|
||||
isDeleted
|
||||
? 'opacity-60'
|
||||
: 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: userColor }}
|
||||
title={entry.user_display_name}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-bold ${ACTION_COLORS[entry.action_type] || 'text-zinc-500'}`}>
|
||||
{ACTION_ICONS[entry.action_type] || '?'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => !isDeleted && onSelectEntity(entry.entity_id, entry.action_type)}
|
||||
disabled={isDeleted}
|
||||
className={`truncate text-xs font-medium text-zinc-800 dark:text-zinc-200 ${!isDeleted ? 'hover:underline' : ''}`}
|
||||
>
|
||||
{ACTION_LABELS[entry.action_type] || entry.action_type}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{getEntityDescription(entry)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
<span>{entry.user_display_name}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(entry.created_at)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRevertEntry(entry)
|
||||
}}
|
||||
className="hidden rounded px-1.5 py-0.5 text-[10px] font-medium text-amber-600 hover:bg-amber-50 group-hover:inline-block dark:text-amber-400 dark:hover:bg-amber-900/30"
|
||||
title="Revert this change"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && hasMore && entries.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{revertEntry && (
|
||||
<RevertConfirmDialog
|
||||
actionType={revertEntry.action_type}
|
||||
entityDescription={getEntityDescription(revertEntry)}
|
||||
previousState={revertEntry.previous_state}
|
||||
newState={revertEntry.new_state}
|
||||
onConfirm={() => {
|
||||
onRevert(revertEntry)
|
||||
setRevertEntry(null)
|
||||
}}
|
||||
onCancel={() => setRevertEntry(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export type CollaborationNotification = {
|
||||
id: string
|
||||
displayName: string
|
||||
type: 'join' | 'leave'
|
||||
color: string
|
||||
}
|
||||
|
||||
type CollaborationToastProps = {
|
||||
notification: CollaborationNotification
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
export default function CollaborationToast({ notification, onDismiss }: CollaborationToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(notification.id)
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [notification.id, onDismiss])
|
||||
|
||||
const message = notification.type === 'join'
|
||||
? `${notification.displayName} joined`
|
||||
: `${notification.displayName} left`
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-zinc-800 px-4 py-2.5 text-sm font-medium text-white shadow-lg dark:bg-zinc-700"
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: notification.color }}
|
||||
/>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
import type { NodeLock } from '@/lib/collaboration/realtime'
|
||||
|
||||
export type NodeLockInfo = NodeLock & { color: string }
|
||||
|
||||
type EditorContextValue = {
|
||||
characters: Character[]
|
||||
onAddCharacter: (name: string, color: string) => string // returns new character id
|
||||
variables: Variable[]
|
||||
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
||||
nodeLocks: Map<string, NodeLockInfo> // nodeId -> lock info
|
||||
onNodeFocus: (nodeId: string) => void
|
||||
onNodeBlur: () => void
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextValue>({
|
||||
@@ -15,6 +21,9 @@ const EditorContext = createContext<EditorContextValue>({
|
||||
onAddCharacter: () => '',
|
||||
variables: [],
|
||||
onAddVariable: () => '',
|
||||
nodeLocks: new Map(),
|
||||
onNodeFocus: () => {},
|
||||
onNodeBlur: () => {},
|
||||
})
|
||||
|
||||
export const EditorProvider = EditorContext.Provider
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
export type ValidationIssue = {
|
||||
nodeId: string
|
||||
nodeType: 'dialogue' | 'choice' | 'variable' | 'edge'
|
||||
contentSnippet: string
|
||||
undefinedReference: string
|
||||
referenceType: 'character' | 'variable'
|
||||
}
|
||||
|
||||
type ExportValidationModalProps = {
|
||||
issues: ValidationIssue[]
|
||||
onExportAnyway: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ExportValidationModal({
|
||||
issues,
|
||||
onExportAnyway,
|
||||
onCancel,
|
||||
}: ExportValidationModalProps) {
|
||||
const characterIssues = issues.filter((i) => i.referenceType === 'character')
|
||||
const variableIssues = issues.filter((i) => i.referenceType === 'variable')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
||||
<div className="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-5 w-5 text-orange-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Export Validation Issues
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{issues.length} undefined reference{issues.length !== 1 ? 's' : ''} found. These nodes/edges reference characters or variables that no longer exist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto px-6 py-4">
|
||||
{characterIssues.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Undefined Characters
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{characterIssues.map((issue, idx) => (
|
||||
<li
|
||||
key={`char-${idx}`}
|
||||
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{issue.nodeType}
|
||||
</span>
|
||||
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{issue.contentSnippet}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
|
||||
ID: {issue.undefinedReference.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variableIssues.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Undefined Variables
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{variableIssues.map((issue, idx) => (
|
||||
<li
|
||||
key={`var-${idx}`}
|
||||
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{issue.nodeType}
|
||||
</span>
|
||||
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{issue.contentSnippet}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
|
||||
ID: {issue.undefinedReference.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportAnyway}
|
||||
className="rounded bg-orange-500 px-4 py-2 text-sm font-medium text-white hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Export anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,65 @@
|
||||
'use client'
|
||||
|
||||
import type { PresenceUser } from '@/lib/collaboration/realtime'
|
||||
|
||||
type PresenceAvatarsProps = {
|
||||
users: PresenceUser[]
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 5
|
||||
|
||||
// Generate a consistent color from a user ID hash
|
||||
function getUserColor(userId: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||
}
|
||||
const colors = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
// Get initials from a display name (first letter of first two words)
|
||||
function getInitials(displayName: string): string {
|
||||
const parts = displayName.trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
}
|
||||
return displayName.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export default function PresenceAvatars({ users }: PresenceAvatarsProps) {
|
||||
if (users.length === 0) return null
|
||||
|
||||
const visibleUsers = users.slice(0, MAX_VISIBLE)
|
||||
const overflow = users.length - MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<div className="flex items-center -space-x-2">
|
||||
{visibleUsers.map((user) => {
|
||||
const color = getUserColor(user.userId)
|
||||
return (
|
||||
<div
|
||||
key={user.userId}
|
||||
title={user.displayName}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-semibold text-white dark:border-zinc-800"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{getInitials(user.displayName)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-zinc-400 text-[10px] font-semibold text-white dark:border-zinc-800 dark:bg-zinc-500"
|
||||
title={`${overflow} more collaborator${overflow > 1 ? 's' : ''}`}
|
||||
>
|
||||
+{overflow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
getCollaborators,
|
||||
inviteCollaborator,
|
||||
updateCollaboratorRole,
|
||||
removeCollaborator,
|
||||
type Collaborator,
|
||||
} from '@/app/editor/[projectId]/actions'
|
||||
|
||||
type ShareModalProps = {
|
||||
projectId: string
|
||||
isOwner: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ShareModal({ projectId, isOwner, onClose }: ShareModalProps) {
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
|
||||
const [inviting, setInviting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
const fetchCollaborators = useCallback(async () => {
|
||||
const result = await getCollaborators(projectId)
|
||||
if (result.success && result.data) {
|
||||
setCollaborators(result.data)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollaborators()
|
||||
}, [fetchCollaborators])
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setInviting(true)
|
||||
setError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
const result = await inviteCollaborator(projectId, email.trim(), role)
|
||||
if (result.success) {
|
||||
setEmail('')
|
||||
setSuccessMessage('Collaborator invited successfully')
|
||||
fetchCollaborators()
|
||||
} else {
|
||||
setError(result.error || 'Failed to invite collaborator')
|
||||
}
|
||||
setInviting(false)
|
||||
}
|
||||
|
||||
const handleRoleChange = async (collaboratorId: string, newRole: 'editor' | 'viewer') => {
|
||||
setError(null)
|
||||
const result = await updateCollaboratorRole(projectId, collaboratorId, newRole)
|
||||
if (result.success) {
|
||||
setCollaborators((prev) =>
|
||||
prev.map((c) => (c.id === collaboratorId ? { ...c, role: newRole } : c))
|
||||
)
|
||||
} else {
|
||||
setError(result.error || 'Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (collaboratorId: string) => {
|
||||
setError(null)
|
||||
const result = await removeCollaborator(projectId, collaboratorId)
|
||||
if (result.success) {
|
||||
setCollaborators((prev) => prev.filter((c) => c.id !== collaboratorId))
|
||||
} else {
|
||||
setError(result.error || 'Failed to remove collaborator')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Share Project
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Invite Form - only for owners */}
|
||||
{isOwner && (
|
||||
<form onSubmit={handleInvite} className="mb-6">
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
|
||||
Invite collaborator by email
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={inviting}
|
||||
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'editor' | 'viewer')}
|
||||
disabled={inviting}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50"
|
||||
>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviting || !email.trim()}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{inviting ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collaborators List */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
Collaborators
|
||||
</h3>
|
||||
{loading ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">Loading...</p>
|
||||
) : collaborators.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
No collaborators yet. Invite someone to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{collaborators.map((collab) => (
|
||||
<div
|
||||
key={collab.id}
|
||||
className="flex items-center justify-between rounded-md border border-zinc-200 p-3 dark:border-zinc-700"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-zinc-900 truncate dark:text-zinc-50">
|
||||
{collab.display_name || collab.email || 'Unknown user'}
|
||||
</p>
|
||||
{collab.display_name && collab.email && (
|
||||
<p className="text-xs text-zinc-500 truncate dark:text-zinc-400">
|
||||
{collab.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
{isOwner ? (
|
||||
<>
|
||||
<select
|
||||
value={collab.role}
|
||||
onChange={(e) =>
|
||||
handleRoleChange(collab.id, e.target.value as 'editor' | 'viewer')
|
||||
}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs text-zinc-700 focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRemove(collab.id)}
|
||||
className="rounded p-1 text-zinc-400 hover:text-red-600 dark:hover:text-red-400"
|
||||
title="Remove collaborator"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="rounded bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">
|
||||
{collab.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
|
||||
import PresenceAvatars from './PresenceAvatars'
|
||||
|
||||
type ToolbarProps = {
|
||||
onAddDialogue: () => void
|
||||
onAddChoice: () => void
|
||||
onAddVariable: () => void
|
||||
onSave: () => void
|
||||
isSaving?: boolean
|
||||
onExport: () => void
|
||||
onExportRenpy: () => void
|
||||
onImport: () => void
|
||||
onProjectSettings: () => void
|
||||
onShare: () => void
|
||||
onHistory: () => void
|
||||
connectionState?: ConnectionState
|
||||
presenceUsers?: PresenceUser[]
|
||||
}
|
||||
|
||||
const connectionLabel: Record<ConnectionState, string> = {
|
||||
connecting: 'Connecting…',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting…',
|
||||
}
|
||||
|
||||
const connectionColor: Record<ConnectionState, string> = {
|
||||
connecting: 'bg-yellow-400',
|
||||
connected: 'bg-green-400',
|
||||
disconnected: 'bg-red-400',
|
||||
reconnecting: 'bg-yellow-400',
|
||||
}
|
||||
|
||||
export default function Toolbar({
|
||||
@@ -16,14 +39,28 @@ export default function Toolbar({
|
||||
onAddChoice,
|
||||
onAddVariable,
|
||||
onSave,
|
||||
isSaving,
|
||||
onExport,
|
||||
onExportRenpy,
|
||||
onImport,
|
||||
onProjectSettings,
|
||||
onShare,
|
||||
onHistory,
|
||||
connectionState,
|
||||
presenceUsers,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mr-2 flex items-center gap-1 text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
title="Back to Dashboard"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</Link>
|
||||
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
Add Node:
|
||||
</span>
|
||||
@@ -48,12 +85,37 @@ export default function Toolbar({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{presenceUsers && presenceUsers.length > 0 && (
|
||||
<div className="mr-2">
|
||||
<PresenceAvatars users={presenceUsers} />
|
||||
</div>
|
||||
)}
|
||||
{connectionState && (
|
||||
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[connectionState]}>
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{connectionLabel[connectionState]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onProjectSettings}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Project Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={onShare}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
onClick={onHistory}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ChoiceOption = {
|
||||
@@ -23,8 +24,14 @@ const MAX_OPTIONS = 6
|
||||
|
||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
// --- Handlers de Atualização ---
|
||||
|
||||
const updatePrompt = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setNodes((nodes) =>
|
||||
@@ -59,26 +66,43 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const updateOptionCondition = useCallback(
|
||||
(optionId: string, condition: Condition | undefined) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) =>
|
||||
opt.id === optionId ? { ...opt, condition } : opt
|
||||
),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
const handleSaveCondition = useCallback((optionId: string, condition: Condition) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) =>
|
||||
opt.id === optionId ? { ...opt, condition } : opt
|
||||
),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
)
|
||||
setEditingConditionOptionId(null)
|
||||
}, [id, setNodes])
|
||||
|
||||
const handleRemoveCondition = useCallback((optionId: string) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) =>
|
||||
opt.id === optionId ? { ...opt, condition: undefined } : opt
|
||||
),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
)
|
||||
setEditingConditionOptionId(null)
|
||||
}, [id, setNodes])
|
||||
|
||||
const addOption = useCallback(() => {
|
||||
if (data.options.length >= MAX_OPTIONS) return
|
||||
@@ -89,10 +113,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: [
|
||||
...node.data.options,
|
||||
{ id: nanoid(), label: '' },
|
||||
],
|
||||
options: [...node.data.options, { id: nanoid(), label: '' }],
|
||||
},
|
||||
}
|
||||
: node
|
||||
@@ -110,9 +131,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.filter(
|
||||
(opt: ChoiceOption) => opt.id !== optionId
|
||||
),
|
||||
options: node.data.options.filter((opt: ChoiceOption) => opt.id !== optionId),
|
||||
},
|
||||
}
|
||||
: node
|
||||
@@ -122,6 +141,8 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
[id, data.options.length, setNodes]
|
||||
)
|
||||
|
||||
// --- Auxiliares ---
|
||||
|
||||
const editingOption = useMemo(() => {
|
||||
if (!editingConditionOptionId) return null
|
||||
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
||||
@@ -135,185 +156,119 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
[variables]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="input"
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
/>
|
||||
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
||||
Choice
|
||||
<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" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.prompt || ''}
|
||||
onChange={updatePrompt}
|
||||
placeholder="What do you choose?"
|
||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.options.map((option, index) => (
|
||||
<div key={option.id}>
|
||||
<div className="relative flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingConditionOptionId(option.id)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||
option.condition?.variableId
|
||||
? hasInvalidConditionReference(option)
|
||||
? 'bg-orange-100 text-orange-600 ring-1 ring-orange-500 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
title={option.condition?.variableId ? 'Edit condition' : 'Add condition'}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(option.id)}
|
||||
disabled={data.options.length <= MIN_OPTIONS}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||
title="Remove option"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`option-${index}`}
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
style={{
|
||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{option.condition?.variableId && (
|
||||
<div className={`mt-0.5 ml-1 text-[10px] ${
|
||||
hasInvalidConditionReference(option)
|
||||
? 'text-orange-500 dark:text-orange-400'
|
||||
: 'text-zinc-500 dark:text-zinc-400'
|
||||
}`}>
|
||||
if {option.condition.variableName} {option.condition.operator} {String(option.condition.value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={data.options.length >= MAX_OPTIONS}
|
||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||||
title="Add option"
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase text-green-600">Choice Node</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.prompt || ''}
|
||||
onChange={updatePrompt}
|
||||
placeholder="What do you choose?"
|
||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
placeholder="Dialogue prompt..."
|
||||
className="mb-3 w-full rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{data.options.map((option, index) => (
|
||||
<div key={option.id}>
|
||||
<div className="relative flex items-center gap-1">
|
||||
<div key={option.id} className="group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
className="flex-1 rounded border border-zinc-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
/>
|
||||
|
||||
{/* Botão de Condição */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingConditionOptionId(option.id)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||
className={`p-1 rounded transition-colors ${
|
||||
option.condition
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
||||
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
|
||||
? hasInvalidConditionReference(option)
|
||||
? 'bg-red-100 text-red-600 dark:bg-red-900/30'
|
||||
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30'
|
||||
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
||||
>
|
||||
{option.condition ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(option.id)}
|
||||
disabled={data.options.length <= MIN_OPTIONS}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||
title="Remove option"
|
||||
className="text-zinc-400 hover:text-red-500"
|
||||
>
|
||||
×
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`option-${index}`}
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
style={{
|
||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visualização da Condição */}
|
||||
{option.condition && (
|
||||
<div className="ml-1 mt-0.5 flex items-center gap-1">
|
||||
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||||
</span>
|
||||
<div className={`mt-1 text-[9px] font-mono px-1 rounded ${
|
||||
hasInvalidConditionReference(option) ? 'text-red-500 bg-red-50' : 'text-amber-600 bg-amber-50'
|
||||
}`}>
|
||||
IF: {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||||
{hasInvalidConditionReference(option) && " (Variable Missing!)"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={option.id}
|
||||
style={{ left: `${((index + 1) / (data.options.length + 1)) * 100}%` }}
|
||||
className="!bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={data.options.length >= MAX_OPTIONS}
|
||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||||
title="Add option"
|
||||
className="mt-3 w-full border border-dashed border-zinc-300 py-1 text-[10px] text-zinc-500 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
|
||||
{/* Modal de Edição de Condição */}
|
||||
{editingOption && (
|
||||
<OptionConditionEditor
|
||||
optionId={editingOption.id}
|
||||
optionLabel={editingOption.label}
|
||||
condition={editingOption.condition}
|
||||
onSave={handleSaveCondition}
|
||||
onRemove={handleRemoveCondition}
|
||||
onCancel={() => setEditingConditionOptionId(null)}
|
||||
onChange={(cond) => {
|
||||
if (cond) {
|
||||
handleSaveCondition(editingOption.id, cond)
|
||||
} else {
|
||||
handleRemoveCondition(editingOption.id)
|
||||
}
|
||||
}}
|
||||
onClose={() => setEditingConditionOptionId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
|
||||
type DialogueNodeData = {
|
||||
speaker?: string
|
||||
@@ -24,7 +25,10 @@ function randomColor(): string {
|
||||
|
||||
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { characters, onAddCharacter } = useEditorContext()
|
||||
const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
@@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 ${
|
||||
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-blue-500 dark:border-blue-400'
|
||||
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
|
||||
type VariableNodeData = {
|
||||
variableName: string
|
||||
@@ -15,7 +16,10 @@ type VariableNodeData = {
|
||||
|
||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
const { variables, onAddVariable, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
@@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||
// Filter operations based on selected variable type
|
||||
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 ${
|
||||
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-orange-500 dark:border-orange-400'
|
||||
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import * as Y from 'yjs'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 30_000 // 30s - DB persist is for crash recovery only; CRDT handles real-time sync
|
||||
const BROADCAST_EVENT = 'yjs-update'
|
||||
|
||||
export type CRDTCallbacks = {
|
||||
onNodesChange: (nodes: FlowchartNode[]) => void
|
||||
onEdgesChange: (edges: FlowchartEdge[]) => void
|
||||
onPersist: (nodes: FlowchartNode[], edges: FlowchartEdge[]) => void
|
||||
}
|
||||
|
||||
export class CRDTManager {
|
||||
private doc: Y.Doc
|
||||
private nodesMap: Y.Map<string> // node ID -> JSON string of FlowchartNode
|
||||
private edgesMap: Y.Map<string> // edge ID -> JSON string of FlowchartEdge
|
||||
private channel: RealtimeChannel | null = null
|
||||
private callbacks: CRDTCallbacks
|
||||
private persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private isApplyingRemote = false
|
||||
private isSuppressed = false // suppress broadcast/persist during init or refresh
|
||||
private isDestroyed = false
|
||||
|
||||
constructor(callbacks: CRDTCallbacks) {
|
||||
this.doc = new Y.Doc()
|
||||
this.nodesMap = this.doc.getMap('nodes')
|
||||
this.edgesMap = this.doc.getMap('edges')
|
||||
this.callbacks = callbacks
|
||||
|
||||
// Schedule persistence on local Yjs document changes
|
||||
this.nodesMap.observe(() => {
|
||||
if (this.isApplyingRemote || this.isSuppressed) return
|
||||
this.schedulePersist()
|
||||
})
|
||||
|
||||
this.edgesMap.observe(() => {
|
||||
if (this.isApplyingRemote || this.isSuppressed) return
|
||||
this.schedulePersist()
|
||||
})
|
||||
|
||||
// Broadcast local updates to other clients
|
||||
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin === 'remote' || this.isSuppressed) return
|
||||
this.broadcastUpdate(update)
|
||||
})
|
||||
}
|
||||
|
||||
/** Initialize the Yjs document from database state */
|
||||
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||
this.isSuppressed = true
|
||||
this.doc.transact(() => {
|
||||
nodes.forEach((node) => {
|
||||
this.nodesMap.set(node.id, JSON.stringify(node))
|
||||
})
|
||||
edges.forEach((edge) => {
|
||||
this.edgesMap.set(edge.id, JSON.stringify(edge))
|
||||
})
|
||||
}, 'init')
|
||||
this.isSuppressed = false
|
||||
}
|
||||
|
||||
/** Connect to a Supabase Realtime channel for outbound broadcasts */
|
||||
connectChannel(channel: RealtimeChannel): void {
|
||||
this.channel = channel
|
||||
// Broadcast full state so other clients merge any updates they missed
|
||||
this.broadcastFullState()
|
||||
}
|
||||
|
||||
/** Broadcast the full Yjs document state to sync all connected clients */
|
||||
broadcastFullState(): void {
|
||||
if (!this.channel || this.isDestroyed) return
|
||||
const state = Y.encodeStateAsUpdate(this.doc)
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: BROADCAST_EVENT,
|
||||
payload: { update: Array.from(state) },
|
||||
})
|
||||
}
|
||||
|
||||
/** Apply a remote CRDT update received via broadcast */
|
||||
applyRemoteUpdate(updateData: number[]): void {
|
||||
if (this.isDestroyed) return
|
||||
const update = new Uint8Array(updateData)
|
||||
this.isApplyingRemote = true
|
||||
Y.applyUpdate(this.doc, update, 'remote')
|
||||
this.isApplyingRemote = false
|
||||
// Notify React state of remote changes
|
||||
this.notifyNodesChange()
|
||||
this.notifyEdgesChange()
|
||||
// Note: we do NOT schedulePersist here. Only the originating client
|
||||
// persists its own changes to avoid write races and stale data overwrites.
|
||||
}
|
||||
|
||||
/** Replace CRDT state from a database refresh without broadcasting */
|
||||
refreshFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||
this.isSuppressed = true
|
||||
this.doc.transact(() => {
|
||||
// Sync nodes
|
||||
const nodeIds = new Set(nodes.map((n) => n.id))
|
||||
Array.from(this.nodesMap.keys()).forEach((id) => {
|
||||
if (!nodeIds.has(id)) this.nodesMap.delete(id)
|
||||
})
|
||||
nodes.forEach((node) => {
|
||||
const serialized = JSON.stringify(node)
|
||||
if (this.nodesMap.get(node.id) !== serialized) {
|
||||
this.nodesMap.set(node.id, serialized)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync edges
|
||||
const edgeIds = new Set(edges.map((e) => e.id))
|
||||
Array.from(this.edgesMap.keys()).forEach((id) => {
|
||||
if (!edgeIds.has(id)) this.edgesMap.delete(id)
|
||||
})
|
||||
edges.forEach((edge) => {
|
||||
const serialized = JSON.stringify(edge)
|
||||
if (this.edgesMap.get(edge.id) !== serialized) {
|
||||
this.edgesMap.set(edge.id, serialized)
|
||||
}
|
||||
})
|
||||
}, 'remote')
|
||||
this.isSuppressed = false
|
||||
}
|
||||
|
||||
/** Apply local node changes to the Yjs document */
|
||||
updateNodes(nodes: FlowchartNode[]): void {
|
||||
if (this.isApplyingRemote) return
|
||||
|
||||
this.doc.transact(() => {
|
||||
const currentIds = new Set(nodes.map((n) => n.id))
|
||||
|
||||
// Remove nodes no longer present
|
||||
const existingIds = Array.from(this.nodesMap.keys())
|
||||
existingIds.forEach((id) => {
|
||||
if (!currentIds.has(id)) {
|
||||
this.nodesMap.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Add or update nodes
|
||||
nodes.forEach((node) => {
|
||||
const serialized = JSON.stringify(node)
|
||||
const existing = this.nodesMap.get(node.id)
|
||||
if (existing !== serialized) {
|
||||
this.nodesMap.set(node.id, serialized)
|
||||
}
|
||||
})
|
||||
}, 'local')
|
||||
}
|
||||
|
||||
/** Apply local edge changes to the Yjs document */
|
||||
updateEdges(edges: FlowchartEdge[]): void {
|
||||
if (this.isApplyingRemote) return
|
||||
|
||||
this.doc.transact(() => {
|
||||
const currentIds = new Set(edges.map((e) => e.id))
|
||||
|
||||
// Remove edges no longer present
|
||||
const existingIds = Array.from(this.edgesMap.keys())
|
||||
existingIds.forEach((id) => {
|
||||
if (!currentIds.has(id)) {
|
||||
this.edgesMap.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Add or update edges
|
||||
edges.forEach((edge) => {
|
||||
const serialized = JSON.stringify(edge)
|
||||
const existing = this.edgesMap.get(edge.id)
|
||||
if (existing !== serialized) {
|
||||
this.edgesMap.set(edge.id, serialized)
|
||||
}
|
||||
})
|
||||
}, 'local')
|
||||
}
|
||||
|
||||
/** Get current nodes from the Yjs document */
|
||||
getNodes(): FlowchartNode[] {
|
||||
const nodes: FlowchartNode[] = []
|
||||
this.nodesMap.forEach((value) => {
|
||||
try {
|
||||
nodes.push(JSON.parse(value) as FlowchartNode)
|
||||
} catch {
|
||||
// Skip malformed entries
|
||||
}
|
||||
})
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** Get current edges from the Yjs document */
|
||||
getEdges(): FlowchartEdge[] {
|
||||
const edges: FlowchartEdge[] = []
|
||||
this.edgesMap.forEach((value) => {
|
||||
try {
|
||||
edges.push(JSON.parse(value) as FlowchartEdge)
|
||||
} catch {
|
||||
// Skip malformed entries
|
||||
}
|
||||
})
|
||||
return edges
|
||||
}
|
||||
|
||||
/** Clean up resources */
|
||||
destroy(): void {
|
||||
this.isDestroyed = true
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer)
|
||||
this.persistTimer = null
|
||||
}
|
||||
this.doc.destroy()
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
private notifyNodesChange(): void {
|
||||
this.callbacks.onNodesChange(this.getNodes())
|
||||
}
|
||||
|
||||
private notifyEdgesChange(): void {
|
||||
this.callbacks.onEdgesChange(this.getEdges())
|
||||
}
|
||||
|
||||
private broadcastUpdate(update: Uint8Array): void {
|
||||
if (!this.channel || this.isDestroyed) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: BROADCAST_EVENT,
|
||||
payload: { update: Array.from(update) },
|
||||
})
|
||||
}
|
||||
|
||||
private schedulePersist(): void {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer)
|
||||
}
|
||||
this.persistTimer = setTimeout(() => {
|
||||
if (this.isDestroyed) return
|
||||
this.callbacks.onPersist(this.getNodes(), this.getEdges())
|
||||
}, PERSIST_DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
|
||||
|
||||
export type PresenceUser = {
|
||||
userId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type CursorPosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type RemoteCursor = {
|
||||
userId: string
|
||||
displayName: string
|
||||
position: CursorPosition
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
export type NodeLock = {
|
||||
nodeId: string
|
||||
userId: string
|
||||
displayName: string
|
||||
lockedAt: number
|
||||
}
|
||||
|
||||
type RealtimeCallbacks = {
|
||||
onConnectionStateChange: (state: ConnectionState) => void
|
||||
onPresenceSync?: (users: PresenceUser[]) => void
|
||||
onPresenceJoin?: (user: PresenceUser) => void
|
||||
onPresenceLeave?: (user: PresenceUser) => void
|
||||
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
||||
onCursorUpdate?: (cursor: RemoteCursor) => void
|
||||
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
|
||||
onCRDTUpdate?: (update: number[]) => void
|
||||
onStateRefresh?: () => void
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||
const RECONNECT_BASE_DELAY_MS = 1000
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000
|
||||
const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
|
||||
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection
|
||||
const STALE_THRESHOLD_MS = 3 * 60_000 // 3 minutes hidden before forcing fresh reconnect
|
||||
|
||||
export class RealtimeConnection {
|
||||
private channel: RealtimeChannel | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private connectionTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
private projectId: string
|
||||
private userId: string
|
||||
private displayName: string
|
||||
private callbacks: RealtimeCallbacks
|
||||
private isDestroyed = false
|
||||
private isPaused = false
|
||||
private lastActivityTime = Date.now()
|
||||
private supabase = createClient()
|
||||
|
||||
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
||||
this.projectId = projectId
|
||||
this.userId = userId
|
||||
this.displayName = displayName
|
||||
this.callbacks = callbacks
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.isDestroyed) return
|
||||
this.isPaused = false
|
||||
this.lastActivityTime = Date.now()
|
||||
this.callbacks.onConnectionStateChange('connecting')
|
||||
this.resetInactivityTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
// Ensure the Supabase client has a valid auth session before connecting.
|
||||
// On initial page load, the session may still be loading from cookies.
|
||||
try {
|
||||
const { data: { session } } = await this.supabase.auth.getSession()
|
||||
if (!session && !this.isDestroyed) {
|
||||
// No session yet - wait briefly and retry
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Session check failed - proceed anyway, channel will handle auth errors
|
||||
}
|
||||
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
|
||||
// Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry
|
||||
this.connectionTimer = setTimeout(() => {
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
if (this.channel) {
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
this.scheduleReconnect()
|
||||
}, CONNECTION_TIMEOUT_MS)
|
||||
|
||||
this.channel = this.supabase.channel(`project:${this.projectId}`, {
|
||||
config: { presence: { key: this.userId } },
|
||||
})
|
||||
|
||||
this.channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
if (this.channel) {
|
||||
const state = this.channel.presenceState()
|
||||
const users: PresenceUser[] = []
|
||||
for (const [key, presences] of Object.entries(state)) {
|
||||
if (key === this.userId) continue // Exclude own presence
|
||||
const presence = presences[0] as { userId?: string; displayName?: string } | undefined
|
||||
if (presence?.userId) {
|
||||
users.push({
|
||||
userId: presence.userId,
|
||||
displayName: presence.displayName || 'Anonymous',
|
||||
})
|
||||
}
|
||||
}
|
||||
this.callbacks.onPresenceSync?.(users)
|
||||
}
|
||||
})
|
||||
.on('presence', { event: 'join' }, ({ newPresences }) => {
|
||||
for (const presence of newPresences) {
|
||||
const p = presence as { userId?: string; displayName?: string }
|
||||
if (p.userId && p.userId !== this.userId) {
|
||||
this.callbacks.onPresenceJoin?.({
|
||||
userId: p.userId,
|
||||
displayName: p.displayName || 'Anonymous',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
|
||||
for (const presence of leftPresences) {
|
||||
const p = presence as { userId?: string; displayName?: string }
|
||||
if (p.userId && p.userId !== this.userId) {
|
||||
this.callbacks.onPresenceLeave?.({
|
||||
userId: p.userId,
|
||||
displayName: p.displayName || 'Anonymous',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||
if (payload.userId === this.userId) return
|
||||
this.callbacks.onCursorUpdate?.({
|
||||
userId: payload.userId,
|
||||
displayName: payload.displayName,
|
||||
position: { x: payload.x, y: payload.y },
|
||||
lastUpdated: Date.now(),
|
||||
})
|
||||
})
|
||||
.on('broadcast', { event: 'node-lock' }, ({ payload }) => {
|
||||
if (payload.userId === this.userId) return
|
||||
if (payload.nodeId) {
|
||||
this.callbacks.onNodeLockUpdate?.({
|
||||
nodeId: payload.nodeId,
|
||||
userId: payload.userId,
|
||||
displayName: payload.displayName,
|
||||
lockedAt: payload.lockedAt,
|
||||
}, payload.userId)
|
||||
} else {
|
||||
// null nodeId means unlock
|
||||
this.callbacks.onNodeLockUpdate?.(null, payload.userId)
|
||||
}
|
||||
})
|
||||
.on('broadcast', { event: 'yjs-update' }, ({ payload }) => {
|
||||
if (payload?.update) {
|
||||
this.callbacks.onCRDTUpdate?.(payload.update)
|
||||
}
|
||||
})
|
||||
.on('broadcast', { event: 'state-refresh' }, () => {
|
||||
this.callbacks.onStateRefresh?.()
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (this.isDestroyed) return
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (status === 'SUBSCRIBED') {
|
||||
this.reconnectAttempts = 0
|
||||
this.callbacks.onConnectionStateChange('connected')
|
||||
this.startHeartbeat()
|
||||
await this.createSession()
|
||||
// Track presence with user info
|
||||
await this.channel?.track({
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
})
|
||||
// Notify that the channel is ready for CRDT sync
|
||||
if (this.channel) {
|
||||
this.callbacks.onChannelSubscribed?.(this.channel)
|
||||
}
|
||||
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.scheduleReconnect()
|
||||
} else if (status === 'CLOSED') {
|
||||
if (!this.isPaused) {
|
||||
// Unexpected close - attempt to reconnect
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.callbacks.onConnectionStateChange('disconnected')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.isDestroyed = true
|
||||
this.isPaused = false
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.clearInactivityTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (this.channel) {
|
||||
await this.deleteSession()
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
this.callbacks.onConnectionStateChange('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the connection (e.g. on inactivity or tab hidden).
|
||||
* Unlike disconnect(), this allows resuming later.
|
||||
*/
|
||||
async pause(): Promise<void> {
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
this.isPaused = true
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.clearInactivityTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (this.channel) {
|
||||
await this.deleteSession()
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
this.callbacks.onConnectionStateChange('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the connection after it was paused.
|
||||
* Re-establishes the channel and presence.
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
if (this.isDestroyed || !this.isPaused) return
|
||||
this.isPaused = false
|
||||
this.reconnectAttempts = 0
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that user activity has occurred, resetting the inactivity timer.
|
||||
* If the connection was paused due to inactivity, it will resume.
|
||||
*/
|
||||
notifyActivity(): void {
|
||||
if (this.isDestroyed) return
|
||||
this.lastActivityTime = Date.now()
|
||||
this.resetInactivityTimer()
|
||||
|
||||
if (this.isPaused) {
|
||||
this.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the tab becomes visible again. If the tab was hidden for a
|
||||
* significant period, force a fresh reconnect to handle stale WebSockets.
|
||||
*/
|
||||
notifyVisibilityResumed(): void {
|
||||
if (this.isDestroyed) return
|
||||
const now = Date.now()
|
||||
const hiddenDuration = now - this.lastActivityTime
|
||||
this.lastActivityTime = now
|
||||
this.resetInactivityTimer()
|
||||
|
||||
if (this.isPaused) {
|
||||
this.resume()
|
||||
} else if (hiddenDuration > STALE_THRESHOLD_MS && this.channel) {
|
||||
this.forceReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a fresh reconnect by tearing down the current channel and reconnecting.
|
||||
*/
|
||||
private async forceReconnect(): Promise<void> {
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (this.channel) {
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
getIsPaused(): boolean {
|
||||
return this.isPaused
|
||||
}
|
||||
|
||||
getChannel(): RealtimeChannel | null {
|
||||
return this.channel
|
||||
}
|
||||
|
||||
broadcastCursor(position: CursorPosition): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: {
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
broadcastStateRefresh(): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'state-refresh',
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
|
||||
broadcastNodeLock(nodeId: string | null): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'node-lock',
|
||||
payload: {
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
nodeId,
|
||||
lockedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async createSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabase.from('collaboration_sessions').upsert(
|
||||
{
|
||||
project_id: this.projectId,
|
||||
user_id: this.userId,
|
||||
cursor_position: null,
|
||||
selected_node_id: null,
|
||||
connected_at: new Date().toISOString(),
|
||||
last_heartbeat: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'project_id,user_id' }
|
||||
)
|
||||
} catch {
|
||||
// Fire-and-forget: session creation failure shouldn't block editing
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabase
|
||||
.from('collaboration_sessions')
|
||||
.delete()
|
||||
.eq('project_id', this.projectId)
|
||||
.eq('user_id', this.userId)
|
||||
} catch {
|
||||
// Fire-and-forget: cleanup failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
let consecutiveFailures = 0
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
if (this.isDestroyed || this.isPaused) {
|
||||
this.stopHeartbeat()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the channel is still in a healthy state
|
||||
if (this.channel) {
|
||||
const state = (this.channel as unknown as { state?: string }).state
|
||||
if (state && state !== 'joined' && state !== 'joining') {
|
||||
// Channel is in an unhealthy state - trigger reconnect
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
this.stopHeartbeat()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.supabase
|
||||
.from('collaboration_sessions')
|
||||
.update({ last_heartbeat: new Date().toISOString() })
|
||||
.eq('project_id', this.projectId)
|
||||
.eq('user_id', this.userId)
|
||||
consecutiveFailures = 0
|
||||
} catch {
|
||||
consecutiveFailures++
|
||||
// If heartbeat fails 3 times in a row, the connection is likely dead
|
||||
if (consecutiveFailures >= 3 && this.channel) {
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
this.stopHeartbeat()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.isDestroyed) return
|
||||
this.clearReconnectTimer()
|
||||
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts),
|
||||
RECONNECT_MAX_DELAY_MS
|
||||
)
|
||||
this.reconnectAttempts++
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
if (this.isDestroyed) return
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
|
||||
if (this.channel) {
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
await this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private resetInactivityTimer(): void {
|
||||
this.clearInactivityTimer()
|
||||
if (this.isDestroyed) return
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (!this.isDestroyed && !this.isPaused) {
|
||||
this.pause()
|
||||
}
|
||||
}, INACTIVITY_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private clearInactivityTimer(): void {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer)
|
||||
this.inactivityTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private clearConnectionTimer(): void {
|
||||
if (this.connectionTimer) {
|
||||
clearTimeout(this.connectionTimer)
|
||||
this.connectionTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,6 @@ export type Position = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Condition type for conditional edges and choice options
|
||||
export type Condition = {
|
||||
variableName: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number;
|
||||
=======
|
||||
// Character type: represents a defined character in the project
|
||||
export type Character = {
|
||||
id: string;
|
||||
@@ -34,7 +27,6 @@ export type Condition = {
|
||||
variableId?: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number | string | boolean;
|
||||
>>>>>>> ralph/collaboration-and-character-variables
|
||||
};
|
||||
|
||||
// DialogueNode type: represents character speech/dialogue
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
-- Migration: Add collaboration sessions and audit trail tables
|
||||
-- Supports real-time multi-user editing with presence tracking and change history
|
||||
|
||||
-- =============================================================================
|
||||
-- PROJECT COLLABORATORS TABLE
|
||||
-- =============================================================================
|
||||
-- Tracks which users have access to a project and their role
|
||||
CREATE TABLE IF NOT EXISTS project_collaborators (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
|
||||
invited_at timestamptz DEFAULT now(),
|
||||
accepted_at timestamptz,
|
||||
UNIQUE (project_id, user_id)
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE project_collaborators ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS: Users can view collaborators for projects they belong to
|
||||
CREATE POLICY "Users can view collaborators for their projects"
|
||||
ON project_collaborators
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.uid() = user_id
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Project owners can insert collaborators
|
||||
CREATE POLICY "Owners can add collaborators"
|
||||
ON project_collaborators
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Project owners can update collaborator roles
|
||||
CREATE POLICY "Owners can update collaborators"
|
||||
ON project_collaborators
|
||||
FOR UPDATE
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Project owners can remove collaborators
|
||||
CREATE POLICY "Owners can remove collaborators"
|
||||
ON project_collaborators
|
||||
FOR DELETE
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- COLLABORATION SESSIONS TABLE
|
||||
-- =============================================================================
|
||||
-- Tracks active editing sessions for real-time presence
|
||||
CREATE TABLE IF NOT EXISTS collaboration_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
cursor_position jsonb,
|
||||
selected_node_id text,
|
||||
connected_at timestamptz DEFAULT now(),
|
||||
last_heartbeat timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE collaboration_sessions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS: Users can view sessions for projects they collaborate on
|
||||
CREATE POLICY "Collaborators can view sessions"
|
||||
ON collaboration_sessions
|
||||
FOR SELECT
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Users can insert their own sessions
|
||||
CREATE POLICY "Users can create own sessions"
|
||||
ON collaboration_sessions
|
||||
FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- RLS: Users can update their own sessions (heartbeat, cursor position)
|
||||
CREATE POLICY "Users can update own sessions"
|
||||
ON collaboration_sessions
|
||||
FOR UPDATE
|
||||
USING (auth.uid() = user_id)
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- RLS: Users can delete their own sessions (disconnect)
|
||||
CREATE POLICY "Users can delete own sessions"
|
||||
ON collaboration_sessions
|
||||
FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- AUDIT TRAIL TABLE
|
||||
-- =============================================================================
|
||||
-- Records all node and edge changes for history and revert functionality
|
||||
CREATE TABLE IF NOT EXISTS audit_trail (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
action_type text NOT NULL CHECK (action_type IN ('node_add', 'node_update', 'node_delete', 'edge_add', 'edge_update', 'edge_delete')),
|
||||
entity_id text NOT NULL,
|
||||
previous_state jsonb,
|
||||
new_state jsonb,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE audit_trail ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS: Users can view audit trail for projects they collaborate on
|
||||
CREATE POLICY "Collaborators can view audit trail"
|
||||
ON audit_trail
|
||||
FOR SELECT
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Users can insert audit entries for projects they collaborate on
|
||||
CREATE POLICY "Collaborators can write audit entries"
|
||||
ON audit_trail
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.uid() = user_id
|
||||
AND (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- INDEXES
|
||||
-- =============================================================================
|
||||
-- Index for efficient history queries (paginated by time)
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_trail_project_created
|
||||
ON audit_trail(project_id, created_at DESC);
|
||||
|
||||
-- Index for session lookups by project
|
||||
CREATE INDEX IF NOT EXISTS idx_collaboration_sessions_project
|
||||
ON collaboration_sessions(project_id);
|
||||
|
||||
-- Index for collaborator lookups by project
|
||||
CREATE INDEX IF NOT EXISTS idx_project_collaborators_project
|
||||
ON project_collaborators(project_id);
|
||||
|
||||
-- Index for collaborator lookups by user (for "shared with me" queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_project_collaborators_user
|
||||
ON project_collaborators(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- UPDATE PROJECTS RLS: Allow collaborators to read/write
|
||||
-- =============================================================================
|
||||
-- Collaborators with 'editor' or 'owner' role can view the project
|
||||
CREATE POLICY "Collaborators can view shared projects"
|
||||
ON projects
|
||||
FOR SELECT
|
||||
USING (
|
||||
id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Collaborators with 'editor' or 'owner' role can update the project
|
||||
CREATE POLICY "Collaborators can update shared projects"
|
||||
ON projects
|
||||
FOR UPDATE
|
||||
USING (
|
||||
id IN (
|
||||
SELECT project_id FROM project_collaborators
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
id IN (
|
||||
SELECT project_id FROM project_collaborators
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add unique constraint on collaboration_sessions for (project_id, user_id)
|
||||
-- This ensures a user can only have one active session per project,
|
||||
-- and allows upsert operations for session management.
|
||||
ALTER TABLE collaboration_sessions
|
||||
ADD CONSTRAINT collaboration_sessions_project_user_unique
|
||||
UNIQUE (project_id, user_id);
|
||||
@@ -0,0 +1,174 @@
|
||||
-- Migration: Fix infinite recursion in RLS policies
|
||||
-- Problem: Self-referencing policies and circular dependencies between
|
||||
-- projects <-> project_collaborators cause infinite recursion.
|
||||
-- Solution: Use SECURITY DEFINER functions to bypass RLS for permission checks.
|
||||
|
||||
-- =============================================================================
|
||||
-- HELPER FUNCTIONS (SECURITY DEFINER bypasses RLS)
|
||||
-- =============================================================================
|
||||
|
||||
-- Check if the current user is an admin
|
||||
CREATE OR REPLACE FUNCTION is_admin()
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = auth.uid()
|
||||
AND is_admin = true
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Check if the current user is the owner of a project
|
||||
CREATE OR REPLACE FUNCTION is_project_owner(p_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM projects
|
||||
WHERE id = p_project_id
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Check if the current user is a collaborator on a project (any role)
|
||||
CREATE OR REPLACE FUNCTION is_project_collaborator(p_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM project_collaborators
|
||||
WHERE project_id = p_project_id
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Check if the current user is an editor or owner collaborator on a project
|
||||
CREATE OR REPLACE FUNCTION is_project_editor(p_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM project_collaborators
|
||||
WHERE project_id = p_project_id
|
||||
AND user_id = auth.uid()
|
||||
AND role IN ('owner', 'editor')
|
||||
);
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX PROFILES POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the problematic admin policy (self-references profiles table)
|
||||
DROP POLICY IF EXISTS "Admins can view all profiles" ON profiles;
|
||||
|
||||
-- Recreate using the helper function
|
||||
CREATE POLICY "Admins can view all profiles"
|
||||
ON profiles
|
||||
FOR SELECT
|
||||
USING (is_admin());
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX PROJECT_COLLABORATORS POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the problematic SELECT policy (self-references project_collaborators)
|
||||
DROP POLICY IF EXISTS "Users can view collaborators for their projects" ON project_collaborators;
|
||||
|
||||
-- Recreate without self-reference: user is either the collaborator row's user,
|
||||
-- the project owner, or already a collaborator on that project
|
||||
CREATE POLICY "Users can view collaborators for their projects"
|
||||
ON project_collaborators
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.uid() = user_id
|
||||
OR is_project_owner(project_id)
|
||||
OR is_project_collaborator(project_id)
|
||||
);
|
||||
|
||||
-- Drop and recreate INSERT/UPDATE/DELETE policies to use helper functions
|
||||
DROP POLICY IF EXISTS "Owners can add collaborators" ON project_collaborators;
|
||||
CREATE POLICY "Owners can add collaborators"
|
||||
ON project_collaborators
|
||||
FOR INSERT
|
||||
WITH CHECK (is_project_owner(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS "Owners can update collaborators" ON project_collaborators;
|
||||
CREATE POLICY "Owners can update collaborators"
|
||||
ON project_collaborators
|
||||
FOR UPDATE
|
||||
USING (is_project_owner(project_id))
|
||||
WITH CHECK (is_project_owner(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS "Owners can remove collaborators" ON project_collaborators;
|
||||
CREATE POLICY "Owners can remove collaborators"
|
||||
ON project_collaborators
|
||||
FOR DELETE
|
||||
USING (is_project_owner(project_id));
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX COLLABORATION_SESSIONS POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the SELECT policy that cross-references both projects and project_collaborators
|
||||
DROP POLICY IF EXISTS "Collaborators can view sessions" ON collaboration_sessions;
|
||||
|
||||
CREATE POLICY "Collaborators can view sessions"
|
||||
ON collaboration_sessions
|
||||
FOR SELECT
|
||||
USING (
|
||||
is_project_owner(project_id)
|
||||
OR is_project_collaborator(project_id)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX AUDIT_TRAIL POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "Collaborators can view audit trail" ON audit_trail;
|
||||
CREATE POLICY "Collaborators can view audit trail"
|
||||
ON audit_trail
|
||||
FOR SELECT
|
||||
USING (
|
||||
is_project_owner(project_id)
|
||||
OR is_project_collaborator(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Collaborators can write audit entries" ON audit_trail;
|
||||
CREATE POLICY "Collaborators can write audit entries"
|
||||
ON audit_trail
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.uid() = user_id
|
||||
AND (
|
||||
is_project_owner(project_id)
|
||||
OR is_project_editor(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX PROJECTS POLICIES (collaborator access)
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the policies that query project_collaborators (creating circular deps)
|
||||
DROP POLICY IF EXISTS "Collaborators can view shared projects" ON projects;
|
||||
CREATE POLICY "Collaborators can view shared projects"
|
||||
ON projects
|
||||
FOR SELECT
|
||||
USING (is_project_collaborator(id));
|
||||
|
||||
DROP POLICY IF EXISTS "Collaborators can update shared projects" ON projects;
|
||||
CREATE POLICY "Collaborators can update shared projects"
|
||||
ON projects
|
||||
FOR UPDATE
|
||||
USING (is_project_editor(id))
|
||||
WITH CHECK (is_project_editor(id));
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration: Allow authenticated users to look up profiles for sharing
|
||||
-- Problem: The profiles RLS policies only allow users to see their own profile
|
||||
-- (or admins to see all). This prevents the sharing feature from finding
|
||||
-- users by email since the query is blocked by RLS.
|
||||
-- Solution: Add a SELECT policy allowing any authenticated user to view profiles.
|
||||
|
||||
CREATE POLICY "Authenticated users can view profiles"
|
||||
ON profiles
|
||||
FOR SELECT
|
||||
USING (auth.uid() IS NOT NULL);
|
||||
Reference in New Issue
Block a user