Author SHA1 Message Date
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 cd3ecc4507 fix: only force-reconnect on tab visibility change, not on activity gaps
The 60s stale threshold was triggering during normal use (reading, thinking)
causing constant reconnects and presence join/leave toast spam. Now force-
reconnect only happens when the tab was hidden for 3+ minutes and becomes
visible again. Regular activity (mouse/keyboard) only resets the inactivity
timer without ever forcing a reconnect.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:11:58 -03:00
GHMiranda 76761e878b Merge pull request 'ralph/collaboration-and-character-variables' (#7) from ralph/collaboration-and-character-variables into developing
Reviewed-on: #7
2026-01-23 18:59:45 +00:00
Gustavo Henrique Santos Souza de Miranda bcbad57aa9 Merge branch 'developing' into ralph/collaboration-and-character-variables 2026-01-23 15:59:17 -03:00
Gustavo Henrique Santos Souza de Miranda 8cdba7f858 fix: wrongfull jsx without single parent 2026-01-23 15:17:08 -03:00
Gustavo Henrique Santos Souza de Miranda 190e25228b fix: accepted git conflicts 2026-01-23 12:42:51 -03:00
GHMiranda ac45f14942 Merge pull request 'ralph/vn-flowchart-editor' (#2) from ralph/vn-flowchart-editor into master
Reviewed-on: #2
2026-01-23 07:53:28 +00:00
GHMiranda 654c2a4b62 Merge branch 'master' into ralph/vn-flowchart-editor 2026-01-23 07:52:52 +00:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 4fbc564944 feat: [US-042] - Password reset modal on token arrival
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:51:52 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 d47794ebc9 chore: mark US-041 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:49:54 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 e8a6942cfe feat: [US-041] - Change password for logged-in user
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:49:25 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 ff52df2c28 feat: [US-040] - Conditionals on choice options
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:47:39 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 dd8fcb79cf fix: redirect root page to dashboard instead of default Next.js template
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:31:48 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 2d57e6d337 fix: wrap useSearchParams() in Suspense boundary for login and signup pages
Next.js App Router requires components using useSearchParams() to be
wrapped in a Suspense boundary for static generation. Split login and
signup pages into server component wrappers with Suspense and client
form components.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:21:36 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 85df378b6b chore: mark US-039 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:07:20 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 b9d778b379 feat: [US-039] - Loading and error states
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:06:43 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 f1e92ba1a0 chore: mark US-038 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:04:02 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 b3f7a57623 feat: [US-038] - Unsaved changes warning
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 23:03:34 -03:00
GHMiranda 8e37e1433f Merge pull request 'ralph/vn-flowchart-editor' (#1) from ralph/vn-flowchart-editor into master
Reviewed-on: #1
2026-01-22 21:53:45 +00:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 815940a011 chore: mark US-037 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:27:15 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 f8f8048e53 feat: [US-037] - Export to Ren'Py JSON format
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:26:25 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 ee0a6f9424 feat: [US-036] - Import project from .vnflow file
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:23:02 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 78479f3234 feat: [US-035] - Export project as .vnflow file
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:20:18 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 59cda43987 chore: mark US-034 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:18:14 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 01f5428dd9 feat: [US-034] - Save project to database
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:17:33 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 e8dbd00d4c chore: mark US-033 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:15:21 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 f6ab24c5b3 feat: [US-033] - Auto-save to LocalStorage
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:14:49 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 c3975dd91a chore: mark US-032 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:11:53 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 c431b212ac feat: [US-032] - Display conditions on edges
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:11:28 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 e686719f29 chore: mark US-031 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:09:04 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 fda0903872 feat: [US-031] - Condition editor modal
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:08:37 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 0d8a4059bc chore: mark US-030 as complete and update progress log
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:05:29 -03:00
Gustavo Henrique Santos Souza de MirandaandClaude Opus 4.5 5b404cbe92 feat: [US-030] - Right-click context menu
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:04:54 -03:00
32 changed files with 4171 additions and 826 deletions
+6 -6
View File
@@ -324,7 +324,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 18,
"passes": false,
"passes": true,
"notes": "Dependencies: US-045, US-046"
},
{
@@ -341,7 +341,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 19,
"passes": false,
"passes": true,
"notes": "Dependencies: US-045, US-046"
},
{
@@ -359,7 +359,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 20,
"passes": false,
"passes": true,
"notes": "Dependencies: US-045, US-048"
},
{
@@ -376,7 +376,7 @@
"Typecheck passes"
],
"priority": 21,
"passes": false,
"passes": true,
"notes": "Dependencies: US-043, US-048"
},
{
@@ -394,7 +394,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 22,
"passes": false,
"passes": true,
"notes": "Dependencies: US-051"
},
{
@@ -414,7 +414,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 23,
"passes": false,
"passes": true,
"notes": "Dependencies: US-052, US-048"
}
]
+281 -76
View File
@@ -60,6 +60,16 @@
- `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map<string> for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates.
- CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops.
- For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive.
- For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed
- `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref.
- Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events.
- `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot.
- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user.
- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release.
- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor).
- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. Exports `AuditEntry` type for consumers.
- To prevent double audit recording when programmatically changing nodes/edges (e.g., revert), set a ref guard (`isRevertingRef`) before `setNodes`/`setEdges` and clear it with `setTimeout(() => ..., 0)`. Check the guard in the CRDT sync effects before calling `auditRef.current.recordNodeChanges()`.
- For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect.
---
@@ -201,102 +211,297 @@
- No browser testing tools are available; manual verification is needed.
---
## 2026-01-23 - US-064
- What was implemented: Export validation that scans nodes/edges for undefined character/variable references before exporting, with a warning modal and canvas highlighting
## 2026-01-22 - US-030
- What was implemented: Right-click context menu for canvas, nodes, and edges
- Files changed:
- `src/components/editor/ExportValidationModal.tsx` - New component: warning modal listing validation issues by type (character/variable), showing node type badge and content snippet, with "Export anyway" and "Cancel" buttons
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Updated `handleExport` to scan nodes (dialogue, variable, choice) and edges for characterId/variableId references not matching defined entries; added `performExport`, `handleExportAnyway`, `handleExportCancel` callbacks; added `validationIssues` and `warningNodeIds` state; applied `export-warning-node` className to affected nodes via `styledNodes` memo
- `src/app/globals.css` - Added `.react-flow__node.export-warning-node` CSS with orange outline and pulse animation for highlighted nodes
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
- **Learnings for future iterations:**
- React Flow nodes support a `className` prop that gets applied to the `.react-flow__node` wrapper div, so custom styling can be applied via CSS without modifying node component internals.
- The validation scans four sources of references: dialogue nodes (characterId), variable nodes (variableId), choice node option conditions (variableId), and edge conditions (variableId).
- The `handleExport` is the validation gate; `performExport` is where actual export logic (US-035) will go. When no issues are found, `performExport` is called directly. Otherwise the modal is shown and the user decides.
- Warning highlighting is cleared either by canceling the modal or by proceeding with "Export anyway".
- Pre-existing lint issues in `ConditionEditor.tsx` and `OptionConditionEditor.tsx` (React Compiler `preserve-manual-memoization` errors) are from prior stories and not related to this change.
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
---
## 2026-01-22 - US-031
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
- Files changed:
- src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers
- **Learnings for future iterations:**
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
- Use `edge.data.condition` to access condition object on edges
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
---
## 2026-01-22 - US-032
- What was implemented: Display conditions on edges with dashed styling and labels
- Files changed:
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
- **Learnings for future iterations:**
- Custom React Flow edges use EdgeProps<T> typing where T is the data shape
- Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels
- `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels
- Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow
- Style edges with conditions using strokeDasharray: '5 5' for dashed lines
- Custom edges go in `src/components/editor/edges/` directory
- Use amber color scheme for conditional edges to distinguish from regular edges
---
## 2026-01-22 - US-033
- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI
- **Learnings for future iterations:**
- Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning
- LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts
- Debounce saves with 1 second delay using useRef for timer tracking
- Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges)
- React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`)
- Check `typeof window === 'undefined'` in lazy initializer for SSR safety
- clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save
- JSON.stringify comparison works for flowchart data equality check
---
## 2026-01-22 - US-034
- What was implemented: Save project to database functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications
- src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator
- **Learnings for future iterations:**
- Use createClient() from lib/supabase/client.ts for browser-side database operations
- Supabase update returns { error } object for error handling
- Use async/await with try/catch for async save operations
- Set updated_at manually with new Date().toISOString() for Supabase JSONB updates
- Clear LocalStorage draft after successful save to avoid stale drafts
- Toast state uses object with message and type for flexibility
- Loading spinner SVG with animate-spin class for visual feedback during save
---
## 2026-01-22 - US-035
- What was implemented: Export project as .vnflow file functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop
- src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor
- **Learnings for future iterations:**
- Use Blob with type 'application/json' for JSON file downloads
- JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation
- URL.createObjectURL creates a temporary URL for the blob
- Create temporary anchor element with download attribute to trigger file download
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
---
## 2026-01-22 - US-036
- What was implemented: Import project from .vnflow file functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes
- **Learnings for future iterations:**
- Use hidden `<input type="file">` element with ref to trigger file picker programmatically via `ref.current?.click()`
- Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"`
- Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file
- Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks
- Type guard function `isValidFlowchartData()` validates imported JSON structure before loading
- Track unsaved changes by comparing current state to initialData using JSON.stringify comparison
- Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss
---
## 2026-01-22 - US-037
- What was implemented: Export to Ren'Py JSON format functionality
- Files changed:
- src/components/editor/Toolbar.tsx - added 'Export to Ren'Py' button with purple styling
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented Ren'Py export types, conversion functions, and handleExportRenpy callback
- **Learnings for future iterations:**
- Ren'Py export uses typed interfaces for different node types: RenpyDialogueNode, RenpyMenuNode, RenpyVariableNode
- Find first node by identifying nodes with no incoming edges (not in any edge's target set)
- Use graph traversal (DFS) to organize nodes into labeled sections based on flow
- Choice nodes create branching sections - save current section before processing each branch
- Track visited nodes to detect cycles and create proper labels for jump references
- Labels are generated based on speaker name or incremental counter for uniqueness
- Replace node IDs with proper labels in a second pass after traversal completes
- Include metadata (projectName, exportedAt) at the top level of the export
- Validate JSON output with JSON.parse before download to ensure validity
- Use purple color scheme for Ren'Py-specific button to distinguish from generic export
---
## 2026-01-22 - US-038
- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component
- src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access)
- **Learnings for future iterations:**
- Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef
- lastSavedDataRef is a useRef initialized with initialData and updated after successful save
- Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers
- Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state
- Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal
- Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action
- "(unsaved changes)" indicator shown next to project name when isDirty is true
---
## 2026-01-22 - US-039
- What was implemented: Loading and error states with reusable spinner, error page, and toast retry
- Files changed:
- src/components/LoadingSpinner.tsx - new reusable loading spinner component with size variants and optional message
- src/app/editor/[projectId]/loading.tsx - updated to use LoadingSpinner component
- src/app/editor/[projectId]/page.tsx - replaced notFound() with custom error UI showing "Project Not Found" with back to dashboard link
- src/components/Toast.tsx - added optional action prop for action buttons (e.g., retry)
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated toast state type to include action, save error now shows retry button via handleSaveRef pattern
- **Learnings for future iterations:**
- Use a ref (handleSaveRef) to break circular dependency when a useCallback needs to reference itself for retry logic
- Toast action prop uses `{ label: string; onClick: () => void }` for flexible action buttons
- Don't auto-dismiss toasts that have action buttons (users need time to click them)
- Replace `notFound()` with inline error UI when you need custom styling and navigation links
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
- Link component from next/link is needed in server components for navigation (no useRouter in server components)
---
## 2026-01-22 - US-040
- What was implemented: Conditionals on choice options - per-option visibility conditions
- Files changed:
- src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption
- src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management
- src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value)
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition)
- **Learnings for future iterations:**
- Per-option conditions use the same Condition type as edge conditions
- Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it
- Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings
- OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs
- In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility
- Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not
- Condition badge below option shows "if variableName operator value" text in compact format
---
## 2026-01-22 - US-041
- What was implemented: Change password for logged-in user from settings page
- Files changed:
- src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields)
- src/components/Navbar.tsx - added "Settings" link to navbar
- **Learnings for future iterations:**
- Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check)
- Re-authentication uses signInWithPassword with current password before allowing updateUser
- Supabase getUser() returns current user email needed for re-auth signInWithPassword call
- Password validation: check match and minimum length (6 chars) before making API calls
- Clear form fields after successful password update for security
- Settings link in navbar uses neutral zinc colors to distinguish from admin/action links
---
## 2026-01-22 - US-042
- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL
- Files changed:
- src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form
- src/app/login/page.tsx - integrated PasswordResetModal component on the login page
- **Learnings for future iterations:**
- PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens
- Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh)
- Separate tokenError state from form error state to show different UI (expired link vs. form validation)
- Modal uses fixed positioning with z-50 to overlay above page content
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
---
## 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-043
- What was implemented: Database migration adding project_collaborators, collaboration_sessions, and audit_trail tables with RLS policies and indexes
## 2026-01-23 - US-050
- What was implemented: Join/leave toast notifications when collaborators connect or disconnect from the editing session
- Files changed:
- `supabase/migrations/20260123100000_add_collaboration_and_audit_trail.sql` - New migration with three tables, RLS policies, indexes, and updated projects RLS for collaborator access
- `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:**
- `project_collaborators` has a UNIQUE constraint on (project_id, user_id) to prevent duplicate invitations
- RLS policies for collaboration tables use subqueries to check either project ownership (via `projects.user_id`) or collaboration membership (via `project_collaborators.user_id`)
- The audit_trail insert policy requires both `auth.uid() = user_id` AND project access (owner or editor role) to prevent unauthorized audit writes
- New RLS policies were added to the existing `projects` table to allow collaborators to SELECT and UPDATE (editors/owners only) shared projects
- The audit_trail index uses `created_at DESC` for efficient reverse-chronological pagination in the history sidebar
- `collaboration_sessions.cursor_position` is JSONB to store flexible coordinate data (x, y, and potentially viewport info)
- `collaboration_sessions.selected_node_id` is nullable text since a user may not have any node selected
---
## 2026-01-23 - US-045
- What was implemented: Supabase Realtime channel and connection management with connection lifecycle, heartbeat, auto-reconnect, and toolbar status indicator
- Files changed:
- `src/lib/collaboration/realtime.ts` - New module: `RealtimeConnection` class with connect/disconnect, heartbeat (30s interval), exponential backoff reconnect, session upsert/delete, presence sync callback
- `src/components/editor/Toolbar.tsx` - Added `connectionState` optional prop with color-coded indicator (green=connected, yellow=connecting/reconnecting, red=disconnected)
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `userId` prop, imported `RealtimeConnection`, added `connectionState` state + `useEffect` for connection lifecycle, passed `connectionState` to Toolbar
- `src/app/editor/[projectId]/page.tsx` - Passed `userId={user.id}` to FlowchartEditor component
- `supabase/migrations/20260123200000_add_collaboration_sessions_unique_constraint.sql` - Added UNIQUE(project_id, user_id) constraint for upsert support
- **Learnings for future iterations:**
- Supabase Realtime channel subscription statuses are: `SUBSCRIBED`, `CHANNEL_ERROR`, `TIMED_OUT`, `CLOSED`. Handle each for connection state tracking.
- The `RealtimeConnection` class creates its own Supabase client instance via `createClient()` since the browser client is stateless and cheap to create
- `useRef` is used to hold the connection instance across renders without triggering re-renders. The `useEffect` cleanup calls `disconnect()` to properly clean up.
- The Supabase channel name is `project:{projectId}` — future stories (presence, CRDT) should join the same channel via `realtimeRef.current.getChannel()`
- The `collaboration_sessions` table needed a UNIQUE constraint on (project_id, user_id) for the upsert pattern to work; this was added as a separate migration
- Pre-existing lint errors in ConditionEditor.tsx and OptionConditionEditor.tsx (React Compiler `preserve-manual-memoization`) are unrelated to this story
- 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-23 - US-044
- What was implemented: Project sharing and collaborator management with Share button, modal, server actions, collaborator access to editor, and dashboard shared projects section
## 2026-01-24 - US-049
- What was implemented: Node editing lock indicators that show when another collaborator is editing a node
- Files changed:
- `src/app/editor/[projectId]/actions.ts` - New server actions module: `getCollaborators`, `inviteCollaborator`, `updateCollaboratorRole`, `removeCollaborator` with ownership verification and profile lookups
- `src/components/editor/ShareModal.tsx` - New modal component: invite form (email + role), collaborator list with role change and remove actions, error/success messaging
- `src/components/editor/Toolbar.tsx` - Added `onShare` prop and "Share" button between "Project Settings" and "Save"
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `isOwner` prop, `showShare` state, ShareModal rendering, passed `onShare` to Toolbar
- `src/app/editor/[projectId]/page.tsx` - Updated project fetching to support collaborator access (owner-first lookup, fallback to project_collaborators check), pass `isOwner` prop
- `src/app/dashboard/page.tsx` - Added query for shared projects via `project_collaborators` join, rendered "Shared with me" section with ProjectCard
- `src/components/ProjectCard.tsx` - Made `onDelete`/`onRename` optional, added `shared`/`sharedRole` props, conditionally shows role badge instead of edit/delete actions for shared projects
- `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:**
- Supabase join queries with `profiles(display_name, email)` return the type as an array due to generated types. Cast through `unknown` to the expected shape: `c.profiles as unknown as { display_name: string | null; email: string | null } | null`
- The RLS policies from US-043 migration already handle collaborator access to projects (SELECT and UPDATE for editors). No new migration was needed for this story.
- For editor page.tsx, checking collaborator access requires two queries: first try `.eq('user_id', user.id)` for ownership, then check `project_collaborators` table. RLS on projects allows collaborators to SELECT, so the second `.from('projects')` query works.
- Server actions in App Router can be co-located with page.tsx in the same route directory (e.g., `src/app/editor/[projectId]/actions.ts`)
- The `accepted_at` field is auto-set on invite (auto-accept pattern). A pending invitation flow would require leaving `accepted_at` null and adding an accept action.
- 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-23 - US-046
- What was implemented: Presence indicators showing connected collaborators as avatar circles in the editor toolbar
## 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/realtime.ts` - Added `PresenceUser` type, `displayName` constructor param, presence tracking via `channel.track()`, presence sync parsing that excludes own user
- `src/components/editor/PresenceAvatars.tsx` - New component: avatar circles with user initials, consistent color from user ID hash, tooltip with display_name, max 5 visible with "+N" overflow
- `src/components/editor/Toolbar.tsx` - Added `presenceUsers` prop and `PresenceAvatars` rendering before connection indicator
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `userDisplayName` prop, `presenceUsers` state, updated `RealtimeConnection` instantiation with `displayName` and `onPresenceSync` callback
- `src/app/editor/[projectId]/page.tsx` - Fetches user's `display_name` from profiles table and passes as `userDisplayName` prop
- `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:**
- Supabase Realtime presence uses `channel.track({ ...data })` after subscription to broadcast user info. The presence key is set in channel config: `config: { presence: { key: userId } }`.
- The `presenceState()` returns a `Record<presenceKey, PresencePayload[]>` where each key maps to an array of presence objects. Filter out own key to exclude self.
- Consistent user colors are generated via a simple hash of the userId string, indexed into a fixed palette of 12 colors. This ensures the same user always gets the same color across sessions.
- The `PresenceAvatars` component shows initials (first letter of first two words, or first two chars for single-word names) with the computed background color.
- Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change.
- 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-23 - US-048
- What was implemented: Yjs CRDT integration for conflict-free node/edge synchronization across multiple collaborators
## 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:
- `package.json` / `package-lock.json` - Added `yjs` dependency
- `src/lib/collaboration/crdt.ts` - New module: `CRDTManager` class wrapping Y.Doc with Y.Map for nodes (keyed by node ID) and Y.Map for edges (keyed by edge ID), broadcast via Supabase Realtime channel, debounced persistence (2s)
- `src/lib/collaboration/realtime.ts` - Added `onChannelSubscribed` callback to `RealtimeCallbacks` type, invoked after channel subscription to allow CRDT connection
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added CRDTManager initialization from DB data, channel connection via `onChannelSubscribed`, bidirectional sync (local→CRDT via useEffect on nodes/edges, remote→local via callbacks), Supabase persistence on debounced timer
- `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:**
- Yjs updates are serialized as `Uint8Array`. For Supabase Realtime broadcast (which uses JSON), convert to `Array.from(update)` for sending and `new Uint8Array(data)` for receiving.
- The CRDT uses an `isApplyingRemote` flag to prevent echo loops: when applying remote updates, observers skip re-notifying React state (since the state setter is what triggers the change). Similarly, `isRemoteUpdateRef` in FlowchartEditor prevents local→CRDT sync when the change originated from a remote update.
- Y.Map stores serialized JSON strings (not objects) as values to avoid Yjs's nested type complexity. This means each node/edge is independently merge-able at the entry level.
- The `onPersist` callback captures `characters` and `variables` from the effect closure — this means the persisted data always uses the character/variable state at the time the effect was created. For full correctness in a production system, these should be passed dynamically.
- The CRDT document is initialized once from `migratedData` (the lazy-computed initial state). The `initializeFromData` call uses an 'init' origin to distinguish initialization from local edits.
- The `onChannelSubscribed` callback pattern allows the CRDT to connect to the channel only after it's fully subscribed, avoiding race conditions with broadcast messages arriving before the listener is set up.
- Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change.
- 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.
---
+161
View File
@@ -0,0 +1,161 @@
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (newPassword !== confirmPassword) {
setError('New passwords do not match.')
return
}
if (newPassword.length < 6) {
setError('New password must be at least 6 characters.')
return
}
setIsLoading(true)
try {
const supabase = createClient()
// Re-authenticate with current password
const { data: { user } } = await supabase.auth.getUser()
if (!user?.email) {
setError('Unable to verify current user.')
setIsLoading(false)
return
}
const { error: signInError } = await supabase.auth.signInWithPassword({
email: user.email,
password: currentPassword,
})
if (signInError) {
setError('Current password is incorrect.')
setIsLoading(false)
return
}
// Update to new password
const { error: updateError } = await supabase.auth.updateUser({
password: newPassword,
})
if (updateError) {
setError(updateError.message)
setIsLoading(false)
return
}
setSuccess('Password updated successfully.')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} catch {
setError('An unexpected error occurred.')
} finally {
setIsLoading(false)
}
}
return (
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-8">
Settings
</h1>
<div className="max-w-md">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-4">
Change Password
</h2>
<form onSubmit={handleChangePassword} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-400">
{success}
</div>
)}
<div>
<label
htmlFor="currentPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
>
Current Password
</label>
<input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
>
New Password
</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
>
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-zinc-900"
>
{isLoading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+3 -25
View File
@@ -1,3 +1,5 @@
import LoadingSpinner from '@/components/LoadingSpinner'
export default function EditorLoading() {
return (
<div className="flex h-screen flex-col">
@@ -9,31 +11,7 @@ export default function EditorLoading() {
</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-3">
<svg
className="h-8 w-8 animate-spin text-blue-500"
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>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Loading editor...
</p>
</div>
<LoadingSpinner size="lg" message="Loading editor..." />
</div>
</div>
)
+61 -39
View File
@@ -1,5 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import FlowchartEditor from './FlowchartEditor'
import type { FlowchartData } from '@/types/flowchart'
@@ -42,7 +41,6 @@ export default async function EditorPage({ params }: PageProps) {
// If not the owner, check if the user is a collaborator
if (!project) {
// RLS on projects allows collaborators to SELECT shared projects
const { data: collab } = await supabase
.from('project_collaborators')
.select('id, role')
@@ -50,23 +48,75 @@ export default async function EditorPage({ params }: PageProps) {
.eq('user_id', user.id)
.single()
if (!collab) {
notFound()
}
if (collab) {
const { data: sharedProject } = await supabase
.from('projects')
.select('id, name, flowchart_data')
.eq('id', projectId)
.single()
if (!sharedProject) {
notFound()
}
project = sharedProject
isOwner = false
}
}
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"
>
<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">
<svg
className="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Project Not Found
</h2>
<p className="max-w-sm text-sm text-zinc-600 dark:text-zinc-400">
The project you&apos;re looking for doesn&apos;t exist or you don&apos;t have access to it.
</p>
<Link
href="/dashboard"
className="mt-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
>
Back to Dashboard
</Link>
</div>
</div>
</div>
)
}
const rawData = project.flowchart_data || {}
const flowchartData: FlowchartData = {
@@ -81,42 +131,14 @@ export default async function EditorPage({ params }: PageProps) {
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"
>
<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>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{project.name}
</h1>
</div>
</header>
<div className="flex-1">
<FlowchartEditor
projectId={project.id}
projectName={project.name}
userId={user.id}
userDisplayName={userDisplayName}
isOwner={isOwner}
initialData={flowchartData}
needsMigration={needsMigration}
/>
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
export default function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Check for success message from password reset
const message = searchParams.get('message')
const successMessage = message === 'password_reset_success'
? 'Your password has been reset successfully. Please sign in with your new password.'
: null
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const supabase = createClient()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
}
return (
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
WebVNWrite
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to your account
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{successMessage && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
</div>
<div className="flex items-center justify-end">
<Link
href="/forgot-password"
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Forgot your password?
</Link>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
)
}
+11 -116
View File
@@ -1,130 +1,25 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Suspense } from 'react'
import LoginForm from './LoginForm'
import PasswordResetModal from '@/components/PasswordResetModal'
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Check for success message from password reset
const message = searchParams.get('message')
const successMessage = message === 'password_reset_success'
? 'Your password has been reset successfully. Please sign in with your new password.'
: null
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const supabase = createClient()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<Suspense
fallback={
<div className="w-full max-w-md space-y-8 text-center">
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
WebVNWrite
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to your account
Loading...
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{successMessage && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
}
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
</div>
<div className="flex items-center justify-end">
<Link
href="/forgot-password"
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Forgot your password?
</Link>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
<LoginForm />
</Suspense>
<PasswordResetModal />
</div>
)
}
+2 -62
View File
@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from 'next/navigation'
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect('/dashboard')
}
+236
View File
@@ -0,0 +1,236 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
export default function SignupForm() {
const router = useRouter()
const searchParams = useSearchParams()
// Pre-fill email if provided in URL (from invite link)
const [email, setEmail] = useState(searchParams.get('email') ?? '')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
// Handle invite/signup token from URL hash
// Supabase adds tokens to the URL hash after redirect
const handleTokenFromUrl = async () => {
const hash = window.location.hash
if (hash) {
const params = new URLSearchParams(hash.substring(1))
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const type = params.get('type')
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
const supabase = createClient()
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (error) {
setError('Invalid or expired invite link. Please request a new invitation.')
return
}
// Get the user's email from the session
const { data: { user } } = await supabase.auth.getUser()
if (user?.email) {
setEmail(user.email)
}
}
}
}
handleTokenFromUrl()
}, [searchParams])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// Validate passwords match
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
// Validate password length
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
const supabase = createClient()
// Check if user already has a session (from invite link)
const { data: { session } } = await supabase.auth.getSession()
if (session) {
// User was invited and has a session - update their password
const { error: updateError } = await supabase.auth.updateUser({
password,
})
if (updateError) {
setError(updateError.message)
setLoading(false)
return
}
// Create profile record
const { error: profileError } = await supabase.from('profiles').upsert({
id: session.user.id,
email: session.user.email,
display_name: session.user.email?.split('@')[0] || 'User',
is_admin: false,
})
if (profileError) {
setError(profileError.message)
setLoading(false)
return
}
router.push('/dashboard')
} else {
// Regular signup flow (if allowed)
const { data, error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
if (data.user) {
// Create profile record
const { error: profileError } = await supabase.from('profiles').upsert({
id: data.user.id,
email: data.user.email,
display_name: email.split('@')[0] || 'User',
is_admin: false,
})
if (profileError) {
setError(profileError.message)
setLoading(false)
return
}
router.push('/dashboard')
}
}
}
return (
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
WebVNWrite
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Complete your account setup
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
<p className="text-center text-sm text-zinc-600 dark:text-zinc-400">
Already have an account?{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Sign in
</Link>
</p>
</form>
</div>
)
}
+9 -224
View File
@@ -1,238 +1,23 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Suspense } from 'react'
import SignupForm from './SignupForm'
export default function SignupPage() {
const router = useRouter()
const searchParams = useSearchParams()
// Pre-fill email if provided in URL (from invite link)
const [email, setEmail] = useState(searchParams.get('email') ?? '')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
// Handle invite/signup token from URL hash
// Supabase adds tokens to the URL hash after redirect
const handleTokenFromUrl = async () => {
const hash = window.location.hash
if (hash) {
const params = new URLSearchParams(hash.substring(1))
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const type = params.get('type')
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
const supabase = createClient()
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (error) {
setError('Invalid or expired invite link. Please request a new invitation.')
return
}
// Get the user's email from the session
const { data: { user } } = await supabase.auth.getUser()
if (user?.email) {
setEmail(user.email)
}
}
}
}
handleTokenFromUrl()
}, [searchParams])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// Validate passwords match
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
// Validate password length
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
const supabase = createClient()
// Check if user already has a session (from invite link)
const { data: { session } } = await supabase.auth.getSession()
if (session) {
// User was invited and has a session - update their password
const { error: updateError } = await supabase.auth.updateUser({
password,
})
if (updateError) {
setError(updateError.message)
setLoading(false)
return
}
// Create profile record
const { error: profileError } = await supabase.from('profiles').upsert({
id: session.user.id,
email: session.user.email,
display_name: session.user.email?.split('@')[0] || 'User',
is_admin: false,
})
if (profileError) {
setError(profileError.message)
setLoading(false)
return
}
router.push('/dashboard')
} else {
// Regular signup flow (if allowed)
const { data, error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
if (data.user) {
// Create profile record
const { error: profileError } = await supabase.from('profiles').upsert({
id: data.user.id,
email: data.user.email,
display_name: email.split('@')[0] || 'User',
is_admin: false,
})
if (profileError) {
setError(profileError.message)
setLoading(false)
return
}
router.push('/dashboard')
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<Suspense
fallback={
<div className="w-full max-w-md space-y-8 text-center">
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
WebVNWrite
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Complete your account setup
Loading...
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
}
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
<p className="text-center text-sm text-zinc-600 dark:text-zinc-400">
Already have an account?{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Sign in
</Link>
</p>
</form>
</div>
<SignupForm />
</Suspense>
</div>
)
}
+40
View File
@@ -0,0 +1,40 @@
type LoadingSpinnerProps = {
size?: 'sm' | 'md' | 'lg'
message?: string
}
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
return (
<div className="flex flex-col items-center gap-3">
<svg
className={`animate-spin text-blue-500 ${sizeClasses[size]}`}
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>
{message && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
)}
</div>
)
}
+6
View File
@@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
Invite User
</Link>
)}
<Link
href="/dashboard/settings"
className="text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-200"
>
Settings
</Link>
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{userEmail}
</span>
+170
View File
@@ -0,0 +1,170 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
export default function PasswordResetModal() {
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [tokenError, setTokenError] = useState<string | null>(null)
useEffect(() => {
const handleTokenFromUrl = async () => {
const hash = window.location.hash
if (!hash) return
const params = new URLSearchParams(hash.substring(1))
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const type = params.get('type')
if (accessToken && refreshToken && type === 'recovery') {
const supabase = createClient()
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (error) {
setTokenError('Invalid or expired reset link. Please request a new password reset.')
return
}
// Clear hash from URL without reloading
window.history.replaceState(null, '', window.location.pathname + window.location.search)
setIsOpen(true)
}
}
handleTokenFromUrl()
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
const supabase = createClient()
const { error: updateError } = await supabase.auth.updateUser({
password,
})
if (updateError) {
setError(updateError.message)
setLoading(false)
return
}
await supabase.auth.signOut()
setIsOpen(false)
router.push('/login?message=password_reset_success')
}
if (tokenError) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Reset link expired
</h2>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{tokenError}
</p>
<button
onClick={() => setTokenError(null)}
className="mt-4 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
>
Close
</button>
</div>
</div>
)
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Set new password
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Enter your new password below.
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div>
<label
htmlFor="reset-password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
New password
</label>
<input
id="reset-password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
<div>
<label
htmlFor="reset-confirm-password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Confirm new password
</label>
<input
id="reset-confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
>
{loading ? 'Updating password...' : 'Update password'}
</button>
</form>
</div>
</div>
)
}
+17 -2
View File
@@ -6,16 +6,23 @@ interface ToastProps {
message: string
type: 'success' | 'error'
onClose: () => void
action?: {
label: string
onClick: () => void
}
}
export default function Toast({ message, type, onClose }: ToastProps) {
export default function Toast({ message, type, onClose, action }: ToastProps) {
useEffect(() => {
// Don't auto-dismiss if there's an action button
if (action) return
const timer = setTimeout(() => {
onClose()
}, 3000)
return () => clearTimeout(timer)
}, [onClose])
}, [onClose, action])
const bgColor =
type === 'success'
@@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) {
>
{icon}
<span>{message}</span>
{action && (
<button
onClick={action.onClick}
className="ml-2 rounded bg-white/20 px-2 py-0.5 text-xs font-semibold hover:bg-white/30"
>
{action.label}
</button>
)}
<button
onClick={onClose}
className="ml-2 rounded p-0.5 hover:bg-white/20"
@@ -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>
)
}
+131
View File
@@ -0,0 +1,131 @@
'use client'
import { useCallback, useEffect } from 'react'
export type ContextMenuType = 'canvas' | 'node' | 'edge'
type ContextMenuProps = {
x: number
y: number
type: ContextMenuType
onClose: () => void
onAddDialogue?: () => void
onAddChoice?: () => void
onAddVariable?: () => void
onDelete?: () => void
onAddCondition?: () => void
}
export default function ContextMenu({
x,
y,
type,
onClose,
onAddDialogue,
onAddChoice,
onAddVariable,
onDelete,
onAddCondition,
}: ContextMenuProps) {
// Close menu on Escape key
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
// Close menu on click outside
const handleClickOutside = useCallback(() => {
onClose()
}, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
}
}, [handleKeyDown, handleClickOutside])
const menuItemClass =
'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer'
return (
<div
className="fixed z-50 min-w-40 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
{type === 'canvas' && (
<>
<button
className={`${menuItemClass} text-blue-600 dark:text-blue-400`}
onClick={() => {
onAddDialogue?.()
onClose()
}}
>
Add Dialogue
</button>
<button
className={`${menuItemClass} text-green-600 dark:text-green-400`}
onClick={() => {
onAddChoice?.()
onClose()
}}
>
Add Choice
</button>
<button
className={`${menuItemClass} text-orange-600 dark:text-orange-400`}
onClick={() => {
onAddVariable?.()
onClose()
}}
>
Add Variable
</button>
</>
)}
{type === 'node' && (
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
)}
{type === 'edge' && (
<>
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
<button
className={menuItemClass}
onClick={() => {
onAddCondition?.()
onClose()
}}
>
Add Condition
</button>
</>
)}
</div>
)
}
+9
View File
@@ -2,12 +2,18 @@
import { createContext, useContext } from 'react'
import type { Character, Variable } from '@/types/flowchart'
import type { NodeLock } from '@/lib/collaboration/realtime'
export type NodeLockInfo = NodeLock & { color: string }
type EditorContextValue = {
characters: Character[]
onAddCharacter: (name: string, color: string) => string // returns new character id
variables: Variable[]
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
nodeLocks: Map<string, NodeLockInfo> // nodeId -> lock info
onNodeFocus: (nodeId: string) => void
onNodeBlur: () => void
}
const EditorContext = createContext<EditorContextValue>({
@@ -15,6 +21,9 @@ const EditorContext = createContext<EditorContextValue>({
onAddCharacter: () => '',
variables: [],
onAddVariable: () => '',
nodeLocks: new Map(),
onNodeFocus: () => {},
onNodeBlur: () => {},
})
export const EditorProvider = EditorContext.Provider
@@ -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>
)
}
+95
View File
@@ -0,0 +1,95 @@
'use client'
import { useEffect, useState } from 'react'
import { useViewport } from 'reactflow'
import type { RemoteCursor } from '@/lib/collaboration/realtime'
type RemoteCursorsProps = {
cursors: RemoteCursor[]
}
const FADE_TIMEOUT_MS = 5000
// Generate a consistent color from a user ID hash (same algorithm as PresenceAvatars)
function getUserColor(userId: string): string {
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
}
const colors = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
]
return colors[Math.abs(hash) % colors.length]
}
export default function RemoteCursors({ cursors }: RemoteCursorsProps) {
const viewport = useViewport()
const [now, setNow] = useState(() => Date.now())
// Tick every second to update fade states
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(timer)
}, [])
return (
<div className="pointer-events-none absolute inset-0 z-40 overflow-hidden">
{cursors.map((cursor) => {
const timeSinceUpdate = now - cursor.lastUpdated
const isFaded = timeSinceUpdate > FADE_TIMEOUT_MS
if (isFaded) return null
const opacity = timeSinceUpdate > FADE_TIMEOUT_MS - 1000
? Math.max(0, 1 - (timeSinceUpdate - (FADE_TIMEOUT_MS - 1000)) / 1000)
: 1
// Convert flow coordinates to screen coordinates
const screenX = cursor.position.x * viewport.zoom + viewport.x
const screenY = cursor.position.y * viewport.zoom + viewport.y
const color = getUserColor(cursor.userId)
return (
<div
key={cursor.userId}
className="absolute left-0 top-0"
style={{
transform: `translate(${screenX}px, ${screenY}px)`,
opacity,
transition: 'transform 80ms linear, opacity 300ms ease',
}}
>
{/* Cursor arrow SVG */}
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
>
<path
d="M0 0L16 12L8 12L6 20L0 0Z"
fill={color}
/>
<path
d="M0 0L16 12L8 12L6 20L0 0Z"
stroke="white"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
{/* User name label */}
<div
className="absolute left-4 top-4 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm"
style={{ backgroundColor: color }}
>
{cursor.displayName}
</div>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,162 @@
'use client'
type RevertConfirmDialogProps = {
actionType: string
entityDescription: string
previousState: Record<string, unknown> | null
newState: Record<string, unknown> | null
onConfirm: () => void
onCancel: () => void
}
const ACTION_LABELS: Record<string, string> = {
node_add: 'node addition',
node_update: 'node update',
node_delete: 'node deletion',
edge_add: 'edge addition',
edge_update: 'edge update',
edge_delete: 'edge deletion',
}
function formatStatePreview(state: Record<string, unknown> | null): string {
if (!state) return '(none)'
const type = state.type as string | undefined
const data = state.data as Record<string, unknown> | undefined
if (type === 'dialogue' && data) {
const speaker = (data.speaker as string) || '(no speaker)'
const text = (data.text as string) || '(no text)'
return `Dialogue: ${speaker}\n"${text.slice(0, 80)}${text.length > 80 ? '…' : ''}"`
}
if (type === 'choice' && data) {
const prompt = (data.prompt as string) || '(no prompt)'
const options = (data.options as { label: string }[]) || []
const optionLabels = options.map((o) => o.label || '(empty)').join(', ')
return `Choice: ${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}\nOptions: ${optionLabels}`
}
if (type === 'variable' && data) {
const name = (data.variableName as string) || '(unnamed)'
const op = (data.operation as string) || 'set'
const val = data.value ?? 0
return `Variable: ${name} ${op} ${val}`
}
// Edge state
if (state.source && state.target) {
const condition = (state.data as Record<string, unknown> | undefined)?.condition as Record<string, unknown> | undefined
if (condition) {
return `Edge: ${state.source} → ${state.target}\nCondition: ${condition.variableName} ${condition.operator} ${condition.value}`
}
return `Edge: ${state.source} → ${state.target}`
}
return JSON.stringify(state, null, 2).slice(0, 200)
}
function getRevertDescription(actionType: string): string {
switch (actionType) {
case 'node_add':
return 'This will delete the node that was added.'
case 'node_update':
return 'This will restore the node to its previous state.'
case 'node_delete':
return 'This will re-create the deleted node.'
case 'edge_add':
return 'This will delete the edge that was added.'
case 'edge_update':
return 'This will restore the edge to its previous state.'
case 'edge_delete':
return 'This will re-create the deleted edge.'
default:
return 'This will undo the change.'
}
}
export default function RevertConfirmDialog({
actionType,
entityDescription,
previousState,
newState,
onConfirm,
onCancel,
}: RevertConfirmDialogProps) {
const label = ACTION_LABELS[actionType] || actionType
const description = getRevertDescription(actionType)
// For revert, the "before" is the current state (newState) and "after" is what we're restoring to (previousState)
const isAddition = actionType.endsWith('_add')
const isDeletion = actionType.endsWith('_delete')
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50" onClick={onCancel}>
<div
className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Revert {label}
</h3>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{entityDescription}
</p>
</div>
<div className="px-5 py-4">
<p className="mb-3 text-sm text-zinc-700 dark:text-zinc-300">
{description}
</p>
<div className="grid grid-cols-2 gap-3">
{!isAddition && (
<div>
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
Current State
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
{formatStatePreview(newState)}
</pre>
</div>
)}
{!isDeletion && (
<div>
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
{isAddition ? 'Will be removed' : 'Restored State'}
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
{isAddition ? formatStatePreview(newState) : formatStatePreview(previousState)}
</pre>
</div>
)}
{isDeletion && (
<div className="col-span-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
Will be restored
</div>
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
{formatStatePreview(previousState)}
</pre>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200 px-5 py-3 dark:border-zinc-700">
<button
onClick={onCancel}
className="rounded px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Cancel
</button>
<button
onClick={onConfirm}
className="rounded bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700"
>
Revert
</button>
</div>
</div>
</div>
)
}
+53 -2
View File
@@ -1,5 +1,6 @@
'use client'
import Link from 'next/link'
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
import PresenceAvatars from './PresenceAvatars'
@@ -8,10 +9,13 @@ type ToolbarProps = {
onAddChoice: () => void
onAddVariable: () => void
onSave: () => void
isSaving?: boolean
onExport: () => void
onExportRenpy: () => void
onImport: () => void
onProjectSettings: () => void
onShare: () => void
onHistory: () => void
connectionState?: ConnectionState
presenceUsers?: PresenceUser[]
}
@@ -35,16 +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>
@@ -95,10 +111,39 @@ export default function Toolbar({
Share
</button>
<button
onClick={onSave}
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"
>
Save
History
</button>
<button
onClick={onSave}
disabled={isSaving}
className="flex items-center gap-1.5 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 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
{isSaving && (
<svg
className="h-4 w-4 animate-spin"
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>
)}
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={onExport}
@@ -106,6 +151,12 @@ export default function Toolbar({
>
Export
</button>
<button
onClick={onExportRenpy}
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
>
Export to Ren&apos;Py
</button>
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 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"
@@ -0,0 +1,71 @@
'use client'
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getSmoothStepPath,
} from 'reactflow'
import type { Condition } from '@/types/flowchart'
type ConditionalEdgeData = {
condition?: Condition
}
export default function ConditionalEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
selected,
}: EdgeProps<ConditionalEdgeData>) {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const hasCondition = !!data?.condition
// Format condition as readable label
const conditionLabel = hasCondition
? `${data.condition!.variableName} ${data.condition!.operator} ${data.condition!.value}`
: null
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
strokeDasharray: hasCondition ? '5 5' : undefined,
stroke: selected ? '#3b82f6' : hasCondition ? '#f59e0b' : '#64748b',
strokeWidth: selected ? 2 : 1.5,
}}
/>
{conditionLabel && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="rounded border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 dark:border-amber-600 dark:bg-amber-900 dark:text-amber-200"
>
{conditionLabel}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
+99 -63
View File
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
import { useEditorContext } from '@/components/editor/EditorContext'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
import type { Condition } from '@/types/flowchart'
type ChoiceOption = {
@@ -23,9 +24,14 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow()
const { variables } = useEditorContext()
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) =>
@@ -60,8 +66,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[id, setNodes]
)
const updateOptionCondition = useCallback(
(optionId: string, condition: Condition | undefined) => {
const handleSaveCondition = useCallback((optionId: string, condition: Condition) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
@@ -77,9 +82,27 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
: node
)
)
setEditingConditionOptionId(null)
}, [id, setNodes])
const handleRemoveCondition = useCallback((optionId: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, condition: undefined } : opt
),
},
[id, setNodes]
}
: node
)
)
setEditingConditionOptionId(null)
}, [id, setNodes])
const addOption = useCallback(() => {
if (data.options.length >= MAX_OPTIONS) return
@@ -90,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
@@ -111,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
@@ -123,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
@@ -136,105 +156,121 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[variables]
)
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"
/>
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
Choice
return (
<div
className="relative min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900"
onFocus={handleFocus}
onBlur={onNodeBlur}
>
{isLockedByOther && (
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
)}
{isLockedByOther && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
Being edited by {lockInfo.displayName}
</span>
</div>
)}
<Handle type="target" position={Position.Top} className="!bg-green-500" />
<div className="mb-2 flex items-center justify-between">
<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 ${
option.condition?.variableId
className={`p-1 rounded transition-colors ${
option.condition
? 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'
? '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?.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 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>
</div>
{/* Visualização da Condição */}
{option.condition && (
<div className={`mt-1 text-[9px] font-mono px-1 rounded ${
hasInvalidConditionReference(option) ? 'text-red-500 bg-red-50' : 'text-amber-600 bg-amber-50'
}`}>
IF: {option.condition.variableName} {option.condition.operator} {option.condition.value}
{hasInvalidConditionReference(option) && " (Variable Missing!)"}
</div>
)}
<Handle
type="source"
position={Position.Bottom}
id={`option-${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}%`,
}}
id={option.id}
style={{ left: `${((index + 1) / (data.options.length + 1)) * 100}%` }}
className="!bg-green-500"
/>
</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"
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>
</div>
{/* Modal de Edição de Condição */}
{editingOption && (
<OptionConditionEditor
condition={editingOption.condition}
onChange={(condition) => updateOptionCondition(editingOption.id, condition)}
onChange={(cond) => {
if (cond) {
handleSaveCondition(editingOption.id, cond)
} else {
handleRemoveCondition(editingOption.id)
}
}}
onClose={() => setEditingConditionOptionId(null)}
/>
)}
</>
</div>
)
}
+22 -2
View File
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type DialogueNodeData = {
speaker?: string
@@ -24,7 +25,10 @@ function randomColor(): string {
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
const { setNodes } = useReactFlow()
const { characters, onAddCharacter } = useEditorContext()
const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
@@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
[updateNodeData]
)
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return (
<div
className={`min-w-[200px] rounded-lg border-2 ${
className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-blue-500 dark:border-blue-400'
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
onFocus={handleFocus}
onBlur={onNodeBlur}
>
{isLockedByOther && (
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
)}
{isLockedByOther && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
Being edited by {lockInfo.displayName}
</span>
</div>
)}
<Handle
type="target"
position={Position.Top}
+22 -2
View File
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
type VariableNodeData = {
variableName: string
@@ -15,7 +16,10 @@ type VariableNodeData = {
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
const { setNodes } = useReactFlow()
const { variables, onAddVariable } = useEditorContext()
const { variables, onAddVariable, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
const lockInfo = nodeLocks.get(id)
const isLockedByOther = !!lockInfo
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
@@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
// Filter operations based on selected variable type
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
const handleFocus = useCallback(() => {
onNodeFocus(id)
}, [id, onNodeFocus])
return (
<div
className={`min-w-[200px] rounded-lg border-2 ${
className={`relative min-w-[200px] rounded-lg border-2 ${
hasInvalidReference
? 'border-orange-500 dark:border-orange-400'
: 'border-orange-500 dark:border-orange-400'
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
onFocus={handleFocus}
onBlur={onNodeBlur}
>
{isLockedByOther && (
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
)}
{isLockedByOther && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
Being edited by {lockInfo.displayName}
</span>
</div>
)}
<Handle
type="target"
position={Position.Top}
+218
View File
@@ -0,0 +1,218 @@
import { createClient } from '@/lib/supabase/client'
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
export type AuditActionType =
| 'node_add'
| 'node_update'
| 'node_delete'
| 'edge_add'
| 'edge_update'
| 'edge_delete'
type PendingAuditEntry = {
actionType: AuditActionType
entityId: string
previousState: unknown | null
newState: unknown | null
timer: ReturnType<typeof setTimeout>
}
const DEBOUNCE_MS = 1000
export class AuditTrailRecorder {
private projectId: string
private userId: string
private previousNodes: Map<string, string> // node ID -> JSON string
private previousEdges: Map<string, string> // edge ID -> JSON string
private pending: Map<string, PendingAuditEntry> // entityId -> pending entry
private isDestroyed = false
constructor(projectId: string, userId: string) {
this.projectId = projectId
this.userId = userId
this.previousNodes = new Map()
this.previousEdges = new Map()
this.pending = new Map()
}
/** Initialize with the current state (no audit entries are created for initial state) */
initialize(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
this.previousNodes.clear()
this.previousEdges.clear()
nodes.forEach((node) => {
this.previousNodes.set(node.id, JSON.stringify(node))
})
edges.forEach((edge) => {
this.previousEdges.set(edge.id, JSON.stringify(edge))
})
}
/** Record changes by diffing current state against previous state */
recordNodeChanges(currentNodes: FlowchartNode[]): void {
if (this.isDestroyed) return
const currentMap = new Map<string, string>()
currentNodes.forEach((node) => {
currentMap.set(node.id, JSON.stringify(node))
})
// Detect additions and updates
for (const [id, serialized] of currentMap) {
const previous = this.previousNodes.get(id)
if (!previous) {
// Node was added
this.scheduleWrite(id, 'node_add', null, JSON.parse(serialized))
} else if (previous !== serialized) {
// Node was updated
this.scheduleWrite(id, 'node_update', JSON.parse(previous), JSON.parse(serialized))
}
}
// Detect deletions
for (const [id, serialized] of this.previousNodes) {
if (!currentMap.has(id)) {
this.scheduleWrite(id, 'node_delete', JSON.parse(serialized), null)
}
}
// Update previous state
this.previousNodes = currentMap
}
/** Record edge changes by diffing current state against previous state */
recordEdgeChanges(currentEdges: FlowchartEdge[]): void {
if (this.isDestroyed) return
const currentMap = new Map<string, string>()
currentEdges.forEach((edge) => {
currentMap.set(edge.id, JSON.stringify(edge))
})
// Detect additions and updates
for (const [id, serialized] of currentMap) {
const previous = this.previousEdges.get(id)
if (!previous) {
// Edge was added
this.scheduleWrite(id, 'edge_add', null, JSON.parse(serialized))
} else if (previous !== serialized) {
// Edge was updated
this.scheduleWrite(id, 'edge_update', JSON.parse(previous), JSON.parse(serialized))
}
}
// Detect deletions
for (const [id, serialized] of this.previousEdges) {
if (!currentMap.has(id)) {
this.scheduleWrite(id, 'edge_delete', JSON.parse(serialized), null)
}
}
// Update previous state
this.previousEdges = currentMap
}
/** Clean up resources */
destroy(): void {
this.isDestroyed = true
// Flush all pending writes immediately
for (const [entityId, entry] of this.pending) {
clearTimeout(entry.timer)
this.writeEntry(entry)
this.pending.delete(entityId)
}
}
private scheduleWrite(
entityId: string,
actionType: AuditActionType,
previousState: unknown | null,
newState: unknown | null
): void {
// If there's already a pending write for this entity, cancel it and update
const existing = this.pending.get(entityId)
if (existing) {
clearTimeout(existing.timer)
// For debounced updates, keep the original previousState but use latest newState
// For type changes (e.g., add then delete within 1s), use the new action type
const mergedPreviousState = existing.actionType === 'node_add' || existing.actionType === 'edge_add'
? null // If it was just added, there's no real previous state
: existing.previousState
const mergedActionType = this.mergeActionTypes(existing.actionType, actionType)
// If an entity was added and then deleted within the debounce window, skip entirely
if (mergedActionType === null) {
this.pending.delete(entityId)
return
}
const entry: PendingAuditEntry = {
actionType: mergedActionType,
entityId,
previousState: mergedPreviousState,
newState,
timer: setTimeout(() => {
this.pending.delete(entityId)
this.writeEntry(entry)
}, DEBOUNCE_MS),
}
this.pending.set(entityId, entry)
} else {
const entry: PendingAuditEntry = {
actionType,
entityId,
previousState,
newState,
timer: setTimeout(() => {
this.pending.delete(entityId)
this.writeEntry(entry)
}, DEBOUNCE_MS),
}
this.pending.set(entityId, entry)
}
}
/** Merge two sequential action types for the same entity */
private mergeActionTypes(
first: AuditActionType,
second: AuditActionType
): AuditActionType | null {
// add + delete = no-op (entity never really existed)
if (
(first === 'node_add' && second === 'node_delete') ||
(first === 'edge_add' && second === 'edge_delete')
) {
return null
}
// add + update = still an add (with the updated state)
if (
(first === 'node_add' && second === 'node_update') ||
(first === 'edge_add' && second === 'edge_update')
) {
return first
}
// For all other cases, use the second (latest) action type
return second
}
/** Fire-and-forget write to Supabase */
private writeEntry(entry: PendingAuditEntry): void {
if (this.isDestroyed) return
const supabase = createClient()
supabase
.from('audit_trail')
.insert({
project_id: this.projectId,
user_id: this.userId,
action_type: entry.actionType,
entity_id: entry.entityId,
previous_state: entry.previousState,
new_state: entry.newState,
})
.then(({ error }) => {
if (error) {
console.error('[AuditTrail] Failed to write audit entry:', error)
}
})
}
}
+57 -14
View File
@@ -2,7 +2,7 @@ import * as Y from 'yjs'
import type { RealtimeChannel } from '@supabase/supabase-js'
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
const PERSIST_DEBOUNCE_MS = 2000
const PERSIST_DEBOUNCE_MS = 30_000 // 30s - DB persist is for crash recovery only; CRDT handles real-time sync
const BROADCAST_EVENT = 'yjs-update'
export type CRDTCallbacks = {
@@ -19,6 +19,7 @@ export class CRDTManager {
private callbacks: CRDTCallbacks
private persistTimer: ReturnType<typeof setTimeout> | null = null
private isApplyingRemote = false
private isSuppressed = false // suppress broadcast/persist during init or refresh
private isDestroyed = false
constructor(callbacks: CRDTCallbacks) {
@@ -27,28 +28,27 @@ export class CRDTManager {
this.edgesMap = this.doc.getMap('edges')
this.callbacks = callbacks
// Listen for remote Yjs document changes
// Schedule persistence on local Yjs document changes
this.nodesMap.observe(() => {
if (this.isApplyingRemote) return
this.notifyNodesChange()
if (this.isApplyingRemote || this.isSuppressed) return
this.schedulePersist()
})
this.edgesMap.observe(() => {
if (this.isApplyingRemote) return
this.notifyEdgesChange()
if (this.isApplyingRemote || this.isSuppressed) return
this.schedulePersist()
})
// Broadcast local updates to other clients
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin === 'remote') return // Don't re-broadcast remote updates
if (origin === 'remote' || this.isSuppressed) return
this.broadcastUpdate(update)
})
}
/** Initialize the Yjs document from database state */
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
this.isSuppressed = true
this.doc.transact(() => {
nodes.forEach((node) => {
this.nodesMap.set(node.id, JSON.stringify(node))
@@ -57,27 +57,70 @@ export class CRDTManager {
this.edgesMap.set(edge.id, JSON.stringify(edge))
})
}, 'init')
this.isSuppressed = false
}
/** Connect to a Supabase Realtime channel for syncing updates */
/** Connect to a Supabase Realtime channel for outbound broadcasts */
connectChannel(channel: RealtimeChannel): void {
this.channel = channel
// Broadcast full state so other clients merge any updates they missed
this.broadcastFullState()
}
// Listen for broadcast updates from other clients
channel.on('broadcast', { event: BROADCAST_EVENT }, (payload) => {
/** Broadcast the full Yjs document state to sync all connected clients */
broadcastFullState(): void {
if (!this.channel || this.isDestroyed) return
const state = Y.encodeStateAsUpdate(this.doc)
this.channel.send({
type: 'broadcast',
event: BROADCAST_EVENT,
payload: { update: Array.from(state) },
})
}
/** Apply a remote CRDT update received via broadcast */
applyRemoteUpdate(updateData: number[]): void {
if (this.isDestroyed) return
const data = payload.payload as { update?: number[] } | undefined
if (data?.update) {
const update = new Uint8Array(data.update)
const update = new Uint8Array(updateData)
this.isApplyingRemote = true
Y.applyUpdate(this.doc, update, 'remote')
this.isApplyingRemote = false
// Notify React state of remote changes
this.notifyNodesChange()
this.notifyEdgesChange()
this.schedulePersist()
// Note: we do NOT schedulePersist here. Only the originating client
// persists its own changes to avoid write races and stale data overwrites.
}
/** Replace CRDT state from a database refresh without broadcasting */
refreshFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
this.isSuppressed = true
this.doc.transact(() => {
// Sync nodes
const nodeIds = new Set(nodes.map((n) => n.id))
Array.from(this.nodesMap.keys()).forEach((id) => {
if (!nodeIds.has(id)) this.nodesMap.delete(id)
})
nodes.forEach((node) => {
const serialized = JSON.stringify(node)
if (this.nodesMap.get(node.id) !== serialized) {
this.nodesMap.set(node.id, serialized)
}
})
// Sync edges
const edgeIds = new Set(edges.map((e) => e.id))
Array.from(this.edgesMap.keys()).forEach((id) => {
if (!edgeIds.has(id)) this.edgesMap.delete(id)
})
edges.forEach((edge) => {
const serialized = JSON.stringify(edge)
if (this.edgesMap.get(edge.id) !== serialized) {
this.edgesMap.set(edge.id, serialized)
}
})
}, 'remote')
this.isSuppressed = false
}
/** Apply local node changes to the Yjs document */
+298 -2
View File
@@ -8,26 +8,58 @@ export type PresenceUser = {
displayName: string
}
export type CursorPosition = {
x: number
y: number
}
export type RemoteCursor = {
userId: string
displayName: string
position: CursorPosition
lastUpdated: number
}
export type NodeLock = {
nodeId: string
userId: string
displayName: string
lockedAt: number
}
type RealtimeCallbacks = {
onConnectionStateChange: (state: ConnectionState) => void
onPresenceSync?: (users: PresenceUser[]) => void
onPresenceJoin?: (user: PresenceUser) => void
onPresenceLeave?: (user: PresenceUser) => void
onChannelSubscribed?: (channel: RealtimeChannel) => void
onCursorUpdate?: (cursor: RemoteCursor) => void
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
onCRDTUpdate?: (update: number[]) => void
onStateRefresh?: () => void
}
const HEARTBEAT_INTERVAL_MS = 30_000
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30_000
const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection
const STALE_THRESHOLD_MS = 3 * 60_000 // 3 minutes hidden before forcing fresh reconnect
export class RealtimeConnection {
private channel: RealtimeChannel | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private inactivityTimer: ReturnType<typeof setTimeout> | null = null
private connectionTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
private projectId: string
private userId: string
private displayName: string
private callbacks: RealtimeCallbacks
private isDestroyed = false
private isPaused = false
private lastActivityTime = Date.now()
private supabase = createClient()
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
@@ -39,7 +71,36 @@ export class RealtimeConnection {
async connect(): Promise<void> {
if (this.isDestroyed) return
this.isPaused = false
this.lastActivityTime = Date.now()
this.callbacks.onConnectionStateChange('connecting')
this.resetInactivityTimer()
this.clearConnectionTimer()
// Ensure the Supabase client has a valid auth session before connecting.
// On initial page load, the session may still be loading from cookies.
try {
const { data: { session } } = await this.supabase.auth.getSession()
if (!session && !this.isDestroyed) {
// No session yet - wait briefly and retry
this.scheduleReconnect()
return
}
} catch {
// Session check failed - proceed anyway, channel will handle auth errors
}
if (this.isDestroyed || this.isPaused) return
// Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry
this.connectionTimer = setTimeout(() => {
if (this.isDestroyed || this.isPaused) return
if (this.channel) {
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.scheduleReconnect()
}, CONNECTION_TIMEOUT_MS)
this.channel = this.supabase.channel(`project:${this.projectId}`, {
config: { presence: { key: this.userId } },
@@ -63,8 +124,62 @@ export class RealtimeConnection {
this.callbacks.onPresenceSync?.(users)
}
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
for (const presence of newPresences) {
const p = presence as { userId?: string; displayName?: string }
if (p.userId && p.userId !== this.userId) {
this.callbacks.onPresenceJoin?.({
userId: p.userId,
displayName: p.displayName || 'Anonymous',
})
}
}
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
for (const presence of leftPresences) {
const p = presence as { userId?: string; displayName?: string }
if (p.userId && p.userId !== this.userId) {
this.callbacks.onPresenceLeave?.({
userId: p.userId,
displayName: p.displayName || 'Anonymous',
})
}
}
})
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
if (payload.userId === this.userId) return
this.callbacks.onCursorUpdate?.({
userId: payload.userId,
displayName: payload.displayName,
position: { x: payload.x, y: payload.y },
lastUpdated: Date.now(),
})
})
.on('broadcast', { event: 'node-lock' }, ({ payload }) => {
if (payload.userId === this.userId) return
if (payload.nodeId) {
this.callbacks.onNodeLockUpdate?.({
nodeId: payload.nodeId,
userId: payload.userId,
displayName: payload.displayName,
lockedAt: payload.lockedAt,
}, payload.userId)
} else {
// null nodeId means unlock
this.callbacks.onNodeLockUpdate?.(null, payload.userId)
}
})
.on('broadcast', { event: 'yjs-update' }, ({ payload }) => {
if (payload?.update) {
this.callbacks.onCRDTUpdate?.(payload.update)
}
})
.on('broadcast', { event: 'state-refresh' }, () => {
this.callbacks.onStateRefresh?.()
})
.subscribe(async (status) => {
if (this.isDestroyed) return
this.clearConnectionTimer()
if (status === 'SUBSCRIBED') {
this.reconnectAttempts = 0
@@ -84,15 +199,24 @@ export class RealtimeConnection {
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else if (status === 'CLOSED') {
if (!this.isPaused) {
// Unexpected close - attempt to reconnect
this.callbacks.onConnectionStateChange('reconnecting')
this.scheduleReconnect()
} else {
this.callbacks.onConnectionStateChange('disconnected')
}
}
})
}
async disconnect(): Promise<void> {
this.isDestroyed = true
this.isPaused = false
this.stopHeartbeat()
this.clearReconnectTimer()
this.clearInactivityTimer()
this.clearConnectionTimer()
if (this.channel) {
await this.deleteSession()
@@ -103,10 +227,133 @@ export class RealtimeConnection {
this.callbacks.onConnectionStateChange('disconnected')
}
/**
* Pause the connection (e.g. on inactivity or tab hidden).
* Unlike disconnect(), this allows resuming later.
*/
async pause(): Promise<void> {
if (this.isDestroyed || this.isPaused) return
this.isPaused = true
this.stopHeartbeat()
this.clearReconnectTimer()
this.clearInactivityTimer()
this.clearConnectionTimer()
if (this.channel) {
await this.deleteSession()
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.callbacks.onConnectionStateChange('disconnected')
}
/**
* Resume the connection after it was paused.
* Re-establishes the channel and presence.
*/
async resume(): Promise<void> {
if (this.isDestroyed || !this.isPaused) return
this.isPaused = false
this.reconnectAttempts = 0
await this.connect()
}
/**
* Notify that user activity has occurred, resetting the inactivity timer.
* If the connection was paused due to inactivity, it will resume.
*/
notifyActivity(): void {
if (this.isDestroyed) return
this.lastActivityTime = Date.now()
this.resetInactivityTimer()
if (this.isPaused) {
this.resume()
}
}
/**
* Called when the tab becomes visible again. If the tab was hidden for a
* significant period, force a fresh reconnect to handle stale WebSockets.
*/
notifyVisibilityResumed(): void {
if (this.isDestroyed) return
const now = Date.now()
const hiddenDuration = now - this.lastActivityTime
this.lastActivityTime = now
this.resetInactivityTimer()
if (this.isPaused) {
this.resume()
} else if (hiddenDuration > STALE_THRESHOLD_MS && this.channel) {
this.forceReconnect()
}
}
/**
* Force a fresh reconnect by tearing down the current channel and reconnecting.
*/
private async forceReconnect(): Promise<void> {
if (this.isDestroyed || this.isPaused) return
this.stopHeartbeat()
this.clearReconnectTimer()
this.clearConnectionTimer()
if (this.channel) {
this.supabase.removeChannel(this.channel)
this.channel = null
}
this.reconnectAttempts = 0
await this.connect()
}
getIsPaused(): boolean {
return this.isPaused
}
getChannel(): RealtimeChannel | null {
return this.channel
}
broadcastCursor(position: CursorPosition): void {
if (!this.channel) return
this.channel.send({
type: 'broadcast',
event: 'cursor',
payload: {
userId: this.userId,
displayName: this.displayName,
x: position.x,
y: position.y,
},
})
}
broadcastStateRefresh(): void {
if (!this.channel) return
this.channel.send({
type: 'broadcast',
event: 'state-refresh',
payload: {},
})
}
broadcastNodeLock(nodeId: string | null): void {
if (!this.channel) return
this.channel.send({
type: 'broadcast',
event: 'node-lock',
payload: {
userId: this.userId,
displayName: this.displayName,
nodeId,
lockedAt: Date.now(),
},
})
}
private async createSession(): Promise<void> {
try {
await this.supabase.from('collaboration_sessions').upsert(
@@ -139,19 +386,44 @@ export class RealtimeConnection {
private startHeartbeat(): void {
this.stopHeartbeat()
let consecutiveFailures = 0
this.heartbeatTimer = setInterval(async () => {
if (this.isDestroyed) {
if (this.isDestroyed || this.isPaused) {
this.stopHeartbeat()
return
}
// Check if the channel is still in a healthy state
if (this.channel) {
const state = (this.channel as unknown as { state?: string }).state
if (state && state !== 'joined' && state !== 'joining') {
// Channel is in an unhealthy state - trigger reconnect
this.callbacks.onConnectionStateChange('reconnecting')
this.supabase.removeChannel(this.channel)
this.channel = null
this.stopHeartbeat()
this.scheduleReconnect()
return
}
}
try {
await this.supabase
.from('collaboration_sessions')
.update({ last_heartbeat: new Date().toISOString() })
.eq('project_id', this.projectId)
.eq('user_id', this.userId)
consecutiveFailures = 0
} catch {
// Heartbeat failure is non-critical
consecutiveFailures++
// If heartbeat fails 3 times in a row, the connection is likely dead
if (consecutiveFailures >= 3 && this.channel) {
this.callbacks.onConnectionStateChange('reconnecting')
this.supabase.removeChannel(this.channel)
this.channel = null
this.stopHeartbeat()
this.scheduleReconnect()
}
}
}, HEARTBEAT_INTERVAL_MS)
}
@@ -192,4 +464,28 @@ export class RealtimeConnection {
this.reconnectTimer = null
}
}
private resetInactivityTimer(): void {
this.clearInactivityTimer()
if (this.isDestroyed) return
this.inactivityTimer = setTimeout(() => {
if (!this.isDestroyed && !this.isPaused) {
this.pause()
}
}, INACTIVITY_TIMEOUT_MS)
}
private clearInactivityTimer(): void {
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer)
this.inactivityTimer = null
}
}
private clearConnectionTimer(): void {
if (this.connectionTimer) {
clearTimeout(this.connectionTimer)
this.connectionTimer = null
}
}
}
@@ -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);