Compare commits
60
Commits
@@ -324,7 +324,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 18,
|
"priority": 18,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-045, US-046"
|
"notes": "Dependencies: US-045, US-046"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 19,
|
"priority": 19,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-045, US-046"
|
"notes": "Dependencies: US-045, US-046"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 20,
|
"priority": 20,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-045, US-048"
|
"notes": "Dependencies: US-045, US-048"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
"Typecheck passes"
|
"Typecheck passes"
|
||||||
],
|
],
|
||||||
"priority": 21,
|
"priority": 21,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-043, US-048"
|
"notes": "Dependencies: US-043, US-048"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -394,7 +394,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 22,
|
"priority": 22,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-051"
|
"notes": "Dependencies: US-051"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 23,
|
"priority": 23,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "Dependencies: US-052, US-048"
|
"notes": "Dependencies: US-052, US-048"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+281
-76
@@ -60,6 +60,16 @@
|
|||||||
- `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map<string> for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates.
|
- `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map<string> for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates.
|
||||||
- CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops.
|
- CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops.
|
||||||
- For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive.
|
- For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive.
|
||||||
|
- For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed
|
||||||
|
- `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref.
|
||||||
|
- Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events.
|
||||||
|
- `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot.
|
||||||
|
- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user.
|
||||||
|
- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release.
|
||||||
|
- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor).
|
||||||
|
- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. Exports `AuditEntry` type for consumers.
|
||||||
|
- To prevent double audit recording when programmatically changing nodes/edges (e.g., revert), set a ref guard (`isRevertingRef`) before `setNodes`/`setEdges` and clear it with `setTimeout(() => ..., 0)`. Check the guard in the CRDT sync effects before calling `auditRef.current.recordNodeChanges()`.
|
||||||
|
- For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -201,102 +211,297 @@
|
|||||||
- No browser testing tools are available; manual verification is needed.
|
- No browser testing tools are available; manual verification is needed.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-064
|
## 2026-01-22 - US-030
|
||||||
- What was implemented: Export validation that scans nodes/edges for undefined character/variable references before exporting, with a warning modal and canvas highlighting
|
- What was implemented: Right-click context menu for canvas, nodes, and edges
|
||||||
- Files changed:
|
- 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/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
|
||||||
- `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/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
|
||||||
- `src/app/globals.css` - Added `.react-flow__node.export-warning-node` CSS with orange outline and pulse animation for highlighted nodes
|
|
||||||
- **Learnings for future iterations:**
|
- **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.
|
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
|
||||||
- The validation scans four sources of references: dialogue nodes (characterId), variable nodes (variableId), choice node option conditions (variableId), and edge conditions (variableId).
|
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
|
||||||
- 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.
|
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
|
||||||
- Warning highlighting is cleared either by canceling the modal or by proceeding with "Export anyway".
|
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
|
||||||
- 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.
|
- 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.
|
- No browser testing tools are available; manual verification is needed.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-043
|
## 2026-01-23 - US-050
|
||||||
- What was implemented: Database migration adding project_collaborators, collaboration_sessions, and audit_trail tables with RLS policies and indexes
|
- What was implemented: Join/leave toast notifications when collaborators connect or disconnect from the editing session
|
||||||
- Files changed:
|
- 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:**
|
- **Learnings for future iterations:**
|
||||||
- `project_collaborators` has a UNIQUE constraint on (project_id, user_id) to prevent duplicate invitations
|
- 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.
|
||||||
- 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 `join` event fires for each newly tracked presence. It includes the presence payload (userId, displayName) that was passed to `channel.track()`.
|
||||||
- The audit_trail insert policy requires both `auth.uid() = user_id` AND project access (owner or editor role) to prevent unauthorized audit writes
|
- Collaboration notifications are positioned `bottom-left` (`left-4`) to avoid overlapping with the existing Toast component which is `bottom-right` (`right-4`).
|
||||||
- New RLS policies were added to the existing `projects` table to allow collaborators to SELECT and UPDATE (editors/owners only) shared projects
|
- 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.
|
||||||
- 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
|
|
||||||
- No browser testing tools are available; manual verification is needed.
|
- No browser testing tools are available; manual verification is needed.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-044
|
## 2026-01-24 - US-049
|
||||||
- What was implemented: Project sharing and collaborator management with Share button, modal, server actions, collaborator access to editor, and dashboard shared projects section
|
- What was implemented: Node editing lock indicators that show when another collaborator is editing a node
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/app/editor/[projectId]/actions.ts` - New server actions module: `getCollaborators`, `inviteCollaborator`, `updateCollaboratorRole`, `removeCollaborator` with ownership verification and profile lookups
|
- `src/lib/collaboration/realtime.ts` - Added `NodeLock` type, `onNodeLockUpdate` callback to `RealtimeCallbacks`, `node-lock` broadcast listener, and `broadcastNodeLock()` method
|
||||||
- `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/NodeLockIndicator.tsx` - New component: renders a colored border and name label overlay on locked nodes
|
||||||
- `src/components/editor/Toolbar.tsx` - Added `onShare` prop and "Share" button between "Project Settings" and "Save"
|
- `src/components/editor/EditorContext.tsx` - Extended context with `NodeLockInfo` type, `nodeLocks` (Map), `onNodeFocus`, and `onNodeBlur` callbacks
|
||||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `isOwner` prop, `showShare` state, ShareModal rendering, passed `onShare` to Toolbar
|
- `src/components/editor/nodes/DialogueNode.tsx` - Added lock detection, `NodeLockIndicator` rendering, "Being edited by [name]" overlay, `onFocus`/`onBlur` handlers
|
||||||
- `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/components/editor/nodes/VariableNode.tsx` - Same lock indicator pattern as DialogueNode
|
||||||
- `src/app/dashboard/page.tsx` - Added query for shared projects via `project_collaborators` join, rendered "Shared with me" section with ProjectCard
|
- `src/components/editor/nodes/ChoiceNode.tsx` - Same lock indicator pattern as DialogueNode
|
||||||
- `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/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:**
|
- **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`
|
- Node lock uses Supabase Realtime broadcast (like cursors) — ephemeral, not persisted to DB. Event name: `node-lock`.
|
||||||
- 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.
|
- Lock broadcasting uses `nodeId: string | null` pattern: non-null to acquire lock, `null` to release. The receiving side maps userId to their current lock.
|
||||||
- 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.
|
- Lock expiry uses a 60-second timeout checked every 5 seconds via `setInterval`. The `lockedAt` timestamp is broadcast with the lock payload.
|
||||||
- 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`)
|
- Each node component accesses lock state via `EditorContext` (`nodeLocks` Map). The `NodeLockInfo` type extends `NodeLock` with a `color` field derived from `getUserColor()`.
|
||||||
- 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.
|
- `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.
|
- No browser testing tools are available; manual verification is needed.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-046
|
## 2026-01-24 - US-051
|
||||||
- What was implemented: Presence indicators showing connected collaborators as avatar circles in the editor toolbar
|
- 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:
|
- 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/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/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/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
|
||||||
- `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
|
|
||||||
- **Learnings for future iterations:**
|
- **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 audit recorder piggybacks on the same CRDT sync effects that already compute `nodesForCRDT`/`edgesForCRDT` — this avoids duplicating the React Flow → FlowchartNode/Edge conversion.
|
||||||
- The `presenceState()` returns a `Record<presenceKey, PresencePayload[]>` where each key maps to an array of presence objects. Filter out own key to exclude self.
|
- 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).
|
||||||
- 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.
|
- 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 `PresenceAvatars` component shows initials (first letter of first two words, or first two chars for single-word names) with the computed background color.
|
- The `destroy()` method flushes pending entries synchronously on unmount, ensuring in-flight edits aren't lost when navigating away.
|
||||||
- Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change.
|
- 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.
|
- No browser testing tools are available; manual verification is needed.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-048
|
## 2026-01-24 - US-053
|
||||||
- What was implemented: Yjs CRDT integration for conflict-free node/edge synchronization across multiple collaborators
|
- 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:
|
- Files changed:
|
||||||
- `package.json` / `package-lock.json` - Added `yjs` dependency
|
- `src/components/editor/RevertConfirmDialog.tsx` - New component: confirmation dialog showing action description, before/after state previews, with Cancel and Revert buttons
|
||||||
- `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/components/editor/ActivityHistorySidebar.tsx` - Added Revert button (shows on hover per entry), `revertEntry` state, `RevertConfirmDialog` rendering, exported `AuditEntry` type, added `onRevert` prop
|
||||||
- `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 `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
|
||||||
- `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
|
|
||||||
- **Learnings for future iterations:**
|
- **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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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 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 `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 `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`.
|
||||||
- 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.
|
- Reverting a `node_add` also removes connected edges to prevent dangling edge references.
|
||||||
- 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.
|
- The RevertConfirmDialog uses `z-[70]` to layer above the ActivityHistorySidebar (`z-40`).
|
||||||
- Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change.
|
- 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.
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,5 @@
|
|||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
|
||||||
export default function EditorLoading() {
|
export default function EditorLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
@@ -9,31 +11,7 @@ export default function EditorLoading() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<LoadingSpinner size="lg" message="Loading editor..." />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import FlowchartEditor from './FlowchartEditor'
|
import FlowchartEditor from './FlowchartEditor'
|
||||||
import type { FlowchartData } from '@/types/flowchart'
|
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 not the owner, check if the user is a collaborator
|
||||||
if (!project) {
|
if (!project) {
|
||||||
// RLS on projects allows collaborators to SELECT shared projects
|
|
||||||
const { data: collab } = await supabase
|
const { data: collab } = await supabase
|
||||||
.from('project_collaborators')
|
.from('project_collaborators')
|
||||||
.select('id, role')
|
.select('id, role')
|
||||||
@@ -50,23 +48,75 @@ export default async function EditorPage({ params }: PageProps) {
|
|||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (!collab) {
|
if (collab) {
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: sharedProject } = await supabase
|
const { data: sharedProject } = await supabase
|
||||||
.from('projects')
|
.from('projects')
|
||||||
.select('id, name, flowchart_data')
|
.select('id, name, flowchart_data')
|
||||||
.eq('id', projectId)
|
.eq('id', projectId)
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (!sharedProject) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
project = sharedProject
|
project = sharedProject
|
||||||
isOwner = false
|
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're looking for doesn't exist or you don'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 rawData = project.flowchart_data || {}
|
||||||
const flowchartData: FlowchartData = {
|
const flowchartData: FlowchartData = {
|
||||||
@@ -81,42 +131,14 @@ export default async function EditorPage({ params }: PageProps) {
|
|||||||
const needsMigration = !rawData.characters && !rawData.variables
|
const needsMigration = !rawData.characters && !rawData.variables
|
||||||
|
|
||||||
return (
|
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
|
<FlowchartEditor
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
userDisplayName={userDisplayName}
|
userDisplayName={userDisplayName}
|
||||||
isOwner={isOwner}
|
isOwner={isOwner}
|
||||||
initialData={flowchartData}
|
initialData={flowchartData}
|
||||||
needsMigration={needsMigration}
|
needsMigration={needsMigration}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,130 +1,25 @@
|
|||||||
'use client'
|
import { Suspense } from 'react'
|
||||||
|
import LoginForm from './LoginForm'
|
||||||
import { useState } from 'react'
|
import PasswordResetModal from '@/components/PasswordResetModal'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { createClient } from '@/lib/supabase/client'
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
<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">
|
<Suspense
|
||||||
<div className="text-center">
|
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">
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
WebVNWrite
|
WebVNWrite
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
Sign in to your account
|
Loading...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<LoginForm />
|
||||||
</label>
|
</Suspense>
|
||||||
<input
|
<PasswordResetModal />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-62
@@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect('/dashboard')
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,238 +1,23 @@
|
|||||||
'use client'
|
import { Suspense } from 'react'
|
||||||
|
import SignupForm from './SignupForm'
|
||||||
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 SignupPage() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
<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">
|
<Suspense
|
||||||
<div className="text-center">
|
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">
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
WebVNWrite
|
WebVNWrite
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
Complete your account setup
|
Loading...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<SignupForm />
|
||||||
</label>
|
</Suspense>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
|
|||||||
Invite User
|
Invite User
|
||||||
</Link>
|
</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">
|
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
{userEmail}
|
{userEmail}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,16 +6,23 @@ interface ToastProps {
|
|||||||
message: string
|
message: string
|
||||||
type: 'success' | 'error'
|
type: 'success' | 'error'
|
||||||
onClose: () => void
|
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(() => {
|
useEffect(() => {
|
||||||
|
// Don't auto-dismiss if there's an action button
|
||||||
|
if (action) return
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
onClose()
|
onClose()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [onClose])
|
}, [onClose, action])
|
||||||
|
|
||||||
const bgColor =
|
const bgColor =
|
||||||
type === 'success'
|
type === 'success'
|
||||||
@@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) {
|
|||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{message}</span>
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
import { createContext, useContext } from 'react'
|
import { createContext, useContext } from 'react'
|
||||||
import type { Character, Variable } from '@/types/flowchart'
|
import type { Character, Variable } from '@/types/flowchart'
|
||||||
|
import type { NodeLock } from '@/lib/collaboration/realtime'
|
||||||
|
|
||||||
|
export type NodeLockInfo = NodeLock & { color: string }
|
||||||
|
|
||||||
type EditorContextValue = {
|
type EditorContextValue = {
|
||||||
characters: Character[]
|
characters: Character[]
|
||||||
onAddCharacter: (name: string, color: string) => string // returns new character id
|
onAddCharacter: (name: string, color: string) => string // returns new character id
|
||||||
variables: Variable[]
|
variables: Variable[]
|
||||||
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
||||||
|
nodeLocks: Map<string, NodeLockInfo> // nodeId -> lock info
|
||||||
|
onNodeFocus: (nodeId: string) => void
|
||||||
|
onNodeBlur: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditorContext = createContext<EditorContextValue>({
|
const EditorContext = createContext<EditorContextValue>({
|
||||||
@@ -15,6 +21,9 @@ const EditorContext = createContext<EditorContextValue>({
|
|||||||
onAddCharacter: () => '',
|
onAddCharacter: () => '',
|
||||||
variables: [],
|
variables: [],
|
||||||
onAddVariable: () => '',
|
onAddVariable: () => '',
|
||||||
|
nodeLocks: new Map(),
|
||||||
|
onNodeFocus: () => {},
|
||||||
|
onNodeBlur: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const EditorProvider = EditorContext.Provider
|
export const EditorProvider = EditorContext.Provider
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { NodeLock } from '@/lib/collaboration/realtime'
|
||||||
|
|
||||||
|
type NodeLockIndicatorProps = {
|
||||||
|
lock: NodeLock
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NodeLockIndicator({ lock, color }: NodeLockIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 rounded-lg border-2"
|
||||||
|
style={{ borderColor: color }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute -top-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{lock.displayName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useViewport } from 'reactflow'
|
||||||
|
import type { RemoteCursor } from '@/lib/collaboration/realtime'
|
||||||
|
|
||||||
|
type RemoteCursorsProps = {
|
||||||
|
cursors: RemoteCursor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
|
// Generate a consistent color from a user ID hash (same algorithm as PresenceAvatars)
|
||||||
|
function getUserColor(userId: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < userId.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
const colors = [
|
||||||
|
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||||
|
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||||
|
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||||
|
]
|
||||||
|
return colors[Math.abs(hash) % colors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RemoteCursors({ cursors }: RemoteCursorsProps) {
|
||||||
|
const viewport = useViewport()
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
// Tick every second to update fade states
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-40 overflow-hidden">
|
||||||
|
{cursors.map((cursor) => {
|
||||||
|
const timeSinceUpdate = now - cursor.lastUpdated
|
||||||
|
const isFaded = timeSinceUpdate > FADE_TIMEOUT_MS
|
||||||
|
if (isFaded) return null
|
||||||
|
|
||||||
|
const opacity = timeSinceUpdate > FADE_TIMEOUT_MS - 1000
|
||||||
|
? Math.max(0, 1 - (timeSinceUpdate - (FADE_TIMEOUT_MS - 1000)) / 1000)
|
||||||
|
: 1
|
||||||
|
|
||||||
|
// Convert flow coordinates to screen coordinates
|
||||||
|
const screenX = cursor.position.x * viewport.zoom + viewport.x
|
||||||
|
const screenY = cursor.position.y * viewport.zoom + viewport.y
|
||||||
|
|
||||||
|
const color = getUserColor(cursor.userId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cursor.userId}
|
||||||
|
className="absolute left-0 top-0"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${screenX}px, ${screenY}px)`,
|
||||||
|
opacity,
|
||||||
|
transition: 'transform 80ms linear, opacity 300ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cursor arrow SVG */}
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 16 20"
|
||||||
|
fill="none"
|
||||||
|
style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0 0L16 12L8 12L6 20L0 0Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 0L16 12L8 12L6 20L0 0Z"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* User name label */}
|
||||||
|
<div
|
||||||
|
className="absolute left-4 top-4 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{cursor.displayName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
type RevertConfirmDialogProps = {
|
||||||
|
actionType: string
|
||||||
|
entityDescription: string
|
||||||
|
previousState: Record<string, unknown> | null
|
||||||
|
newState: Record<string, unknown> | null
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
node_add: 'node addition',
|
||||||
|
node_update: 'node update',
|
||||||
|
node_delete: 'node deletion',
|
||||||
|
edge_add: 'edge addition',
|
||||||
|
edge_update: 'edge update',
|
||||||
|
edge_delete: 'edge deletion',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatePreview(state: Record<string, unknown> | null): string {
|
||||||
|
if (!state) return '(none)'
|
||||||
|
|
||||||
|
const type = state.type as string | undefined
|
||||||
|
const data = state.data as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
if (type === 'dialogue' && data) {
|
||||||
|
const speaker = (data.speaker as string) || '(no speaker)'
|
||||||
|
const text = (data.text as string) || '(no text)'
|
||||||
|
return `Dialogue: ${speaker}\n"${text.slice(0, 80)}${text.length > 80 ? '…' : ''}"`
|
||||||
|
}
|
||||||
|
if (type === 'choice' && data) {
|
||||||
|
const prompt = (data.prompt as string) || '(no prompt)'
|
||||||
|
const options = (data.options as { label: string }[]) || []
|
||||||
|
const optionLabels = options.map((o) => o.label || '(empty)').join(', ')
|
||||||
|
return `Choice: ${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}\nOptions: ${optionLabels}`
|
||||||
|
}
|
||||||
|
if (type === 'variable' && data) {
|
||||||
|
const name = (data.variableName as string) || '(unnamed)'
|
||||||
|
const op = (data.operation as string) || 'set'
|
||||||
|
const val = data.value ?? 0
|
||||||
|
return `Variable: ${name} ${op} ${val}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge state
|
||||||
|
if (state.source && state.target) {
|
||||||
|
const condition = (state.data as Record<string, unknown> | undefined)?.condition as Record<string, unknown> | undefined
|
||||||
|
if (condition) {
|
||||||
|
return `Edge: ${state.source} → ${state.target}\nCondition: ${condition.variableName} ${condition.operator} ${condition.value}`
|
||||||
|
}
|
||||||
|
return `Edge: ${state.source} → ${state.target}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(state, null, 2).slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRevertDescription(actionType: string): string {
|
||||||
|
switch (actionType) {
|
||||||
|
case 'node_add':
|
||||||
|
return 'This will delete the node that was added.'
|
||||||
|
case 'node_update':
|
||||||
|
return 'This will restore the node to its previous state.'
|
||||||
|
case 'node_delete':
|
||||||
|
return 'This will re-create the deleted node.'
|
||||||
|
case 'edge_add':
|
||||||
|
return 'This will delete the edge that was added.'
|
||||||
|
case 'edge_update':
|
||||||
|
return 'This will restore the edge to its previous state.'
|
||||||
|
case 'edge_delete':
|
||||||
|
return 'This will re-create the deleted edge.'
|
||||||
|
default:
|
||||||
|
return 'This will undo the change.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RevertConfirmDialog({
|
||||||
|
actionType,
|
||||||
|
entityDescription,
|
||||||
|
previousState,
|
||||||
|
newState,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: RevertConfirmDialogProps) {
|
||||||
|
const label = ACTION_LABELS[actionType] || actionType
|
||||||
|
const description = getRevertDescription(actionType)
|
||||||
|
|
||||||
|
// For revert, the "before" is the current state (newState) and "after" is what we're restoring to (previousState)
|
||||||
|
const isAddition = actionType.endsWith('_add')
|
||||||
|
const isDeletion = actionType.endsWith('_delete')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
|
||||||
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
Revert {label}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{entityDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<p className="mb-3 text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{!isAddition && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
|
||||||
|
Current State
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||||
|
{formatStatePreview(newState)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isDeletion && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
|
||||||
|
{isAddition ? 'Will be removed' : 'Restored State'}
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||||
|
{isAddition ? formatStatePreview(newState) : formatStatePreview(previousState)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isDeletion && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
|
||||||
|
Will be restored
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||||
|
{formatStatePreview(previousState)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 border-t border-zinc-200 px-5 py-3 dark:border-zinc-700">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="rounded bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
|
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
|
||||||
import PresenceAvatars from './PresenceAvatars'
|
import PresenceAvatars from './PresenceAvatars'
|
||||||
|
|
||||||
@@ -8,10 +9,13 @@ type ToolbarProps = {
|
|||||||
onAddChoice: () => void
|
onAddChoice: () => void
|
||||||
onAddVariable: () => void
|
onAddVariable: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
|
isSaving?: boolean
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onExportRenpy: () => void
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
onProjectSettings: () => void
|
onProjectSettings: () => void
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
|
onHistory: () => void
|
||||||
connectionState?: ConnectionState
|
connectionState?: ConnectionState
|
||||||
presenceUsers?: PresenceUser[]
|
presenceUsers?: PresenceUser[]
|
||||||
}
|
}
|
||||||
@@ -35,16 +39,28 @@ export default function Toolbar({
|
|||||||
onAddChoice,
|
onAddChoice,
|
||||||
onAddVariable,
|
onAddVariable,
|
||||||
onSave,
|
onSave,
|
||||||
|
isSaving,
|
||||||
onExport,
|
onExport,
|
||||||
|
onExportRenpy,
|
||||||
onImport,
|
onImport,
|
||||||
onProjectSettings,
|
onProjectSettings,
|
||||||
onShare,
|
onShare,
|
||||||
|
onHistory,
|
||||||
connectionState,
|
connectionState,
|
||||||
presenceUsers,
|
presenceUsers,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="mr-2 flex items-center gap-1 text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||||
|
title="Back to Dashboard"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||||
Add Node:
|
Add Node:
|
||||||
</span>
|
</span>
|
||||||
@@ -95,10 +111,39 @@ export default function Toolbar({
|
|||||||
Share
|
Share
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
@@ -106,6 +151,12 @@ export default function Toolbar({
|
|||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</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'Py
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onImport}
|
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"
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||||||
|
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||||
import type { Condition } from '@/types/flowchart'
|
import type { Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
type ChoiceOption = {
|
type ChoiceOption = {
|
||||||
@@ -23,9 +24,14 @@ const MAX_OPTIONS = 6
|
|||||||
|
|
||||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
const { variables } = useEditorContext()
|
const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const lockInfo = nodeLocks.get(id)
|
||||||
|
const isLockedByOther = !!lockInfo
|
||||||
|
|
||||||
|
// --- Handlers de Atualização ---
|
||||||
|
|
||||||
const updatePrompt = useCallback(
|
const updatePrompt = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
@@ -60,8 +66,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateOptionCondition = useCallback(
|
const handleSaveCondition = useCallback((optionId: string, condition: Condition) => {
|
||||||
(optionId: string, condition: Condition | undefined) => {
|
|
||||||
setNodes((nodes) =>
|
setNodes((nodes) =>
|
||||||
nodes.map((node) =>
|
nodes.map((node) =>
|
||||||
node.id === id
|
node.id === id
|
||||||
@@ -77,9 +82,27 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||||||
: node
|
: node
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
setEditingConditionOptionId(null)
|
||||||
|
}, [id, setNodes])
|
||||||
|
|
||||||
|
const handleRemoveCondition = useCallback((optionId: string) => {
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((node) =>
|
||||||
|
node.id === id
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
options: node.data.options.map((opt: ChoiceOption) =>
|
||||||
|
opt.id === optionId ? { ...opt, condition: undefined } : opt
|
||||||
|
),
|
||||||
},
|
},
|
||||||
[id, setNodes]
|
}
|
||||||
|
: node
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
setEditingConditionOptionId(null)
|
||||||
|
}, [id, setNodes])
|
||||||
|
|
||||||
const addOption = useCallback(() => {
|
const addOption = useCallback(() => {
|
||||||
if (data.options.length >= MAX_OPTIONS) return
|
if (data.options.length >= MAX_OPTIONS) return
|
||||||
@@ -90,10 +113,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
options: [
|
options: [...node.data.options, { id: nanoid(), label: '' }],
|
||||||
...node.data.options,
|
|
||||||
{ id: nanoid(), label: '' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: node
|
: node
|
||||||
@@ -111,9 +131,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||||||
...node,
|
...node,
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
options: node.data.options.filter(
|
options: node.data.options.filter((opt: ChoiceOption) => opt.id !== optionId),
|
||||||
(opt: ChoiceOption) => opt.id !== optionId
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: node
|
: node
|
||||||
@@ -123,6 +141,8 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||||||
[id, data.options.length, setNodes]
|
[id, data.options.length, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Auxiliares ---
|
||||||
|
|
||||||
const editingOption = useMemo(() => {
|
const editingOption = useMemo(() => {
|
||||||
if (!editingConditionOptionId) return null
|
if (!editingConditionOptionId) return null
|
||||||
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
||||||
@@ -136,105 +156,121 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
|||||||
[variables]
|
[variables]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const handleFocus = useCallback(() => {
|
||||||
<>
|
onNodeFocus(id)
|
||||||
<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">
|
}, [id, onNodeFocus])
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
id="input"
|
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
return (
|
||||||
Choice
|
<div
|
||||||
|
className="relative min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900"
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={onNodeBlur}
|
||||||
|
>
|
||||||
|
{isLockedByOther && (
|
||||||
|
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||||
|
)}
|
||||||
|
{isLockedByOther && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||||
|
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||||
|
Being edited by {lockInfo.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-green-500" />
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold uppercase text-green-600">Choice Node</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={data.prompt || ''}
|
value={data.prompt || ''}
|
||||||
onChange={updatePrompt}
|
onChange={updatePrompt}
|
||||||
placeholder="What do you choose?"
|
placeholder="Dialogue prompt..."
|
||||||
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"
|
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) => (
|
{data.options.map((option, index) => (
|
||||||
<div key={option.id}>
|
<div key={option.id} className="group relative">
|
||||||
<div className="relative flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={option.label}
|
value={option.label}
|
||||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||||
placeholder={`Option ${index + 1}`}
|
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
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => setEditingConditionOptionId(option.id)}
|
onClick={() => setEditingConditionOptionId(option.id)}
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
className={`p-1 rounded transition-colors ${
|
||||||
option.condition?.variableId
|
option.condition
|
||||||
? hasInvalidConditionReference(option)
|
? hasInvalidConditionReference(option)
|
||||||
? 'bg-orange-100 text-orange-600 ring-1 ring-orange-500 dark:bg-orange-900/30 dark:text-orange-400'
|
? 'bg-red-100 text-red-600 dark:bg-red-900/30'
|
||||||
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30'
|
||||||
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
: '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">
|
<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="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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={() => removeOption(option.id)}
|
onClick={() => removeOption(option.id)}
|
||||||
disabled={data.options.length <= MIN_OPTIONS}
|
className="text-zinc-400 hover:text-red-500"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
×
|
<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>
|
</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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id={`option-${index}`}
|
id={option.id}
|
||||||
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}%` }}
|
||||||
style={{
|
className="!bg-green-500"
|
||||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
onClick={addOption}
|
onClick={addOption}
|
||||||
disabled={data.options.length >= MAX_OPTIONS}
|
disabled={data.options.length >= MAX_OPTIONS}
|
||||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
className="mt-3 w-full border border-dashed border-zinc-300 py-1 text-[10px] text-zinc-500 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||||
title="Add option"
|
|
||||||
>
|
>
|
||||||
+ Add Option
|
+ Add Option
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Modal de Edição de Condição */}
|
||||||
{editingOption && (
|
{editingOption && (
|
||||||
<OptionConditionEditor
|
<OptionConditionEditor
|
||||||
condition={editingOption.condition}
|
condition={editingOption.condition}
|
||||||
onChange={(condition) => updateOptionCondition(editingOption.id, condition)}
|
onChange={(cond) => {
|
||||||
|
if (cond) {
|
||||||
|
handleSaveCondition(editingOption.id, cond)
|
||||||
|
} else {
|
||||||
|
handleRemoveCondition(editingOption.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClose={() => setEditingConditionOptionId(null)}
|
onClose={() => setEditingConditionOptionId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
|||||||
import Combobox from '@/components/editor/Combobox'
|
import Combobox from '@/components/editor/Combobox'
|
||||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||||
|
|
||||||
type DialogueNodeData = {
|
type DialogueNodeData = {
|
||||||
speaker?: string
|
speaker?: string
|
||||||
@@ -24,7 +25,10 @@ function randomColor(): string {
|
|||||||
|
|
||||||
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
const { characters, onAddCharacter } = useEditorContext()
|
const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||||
|
|
||||||
|
const lockInfo = nodeLocks.get(id)
|
||||||
|
const isLockedByOther = !!lockInfo
|
||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
@@ -96,14 +100,30 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
|||||||
[updateNodeData]
|
[updateNodeData]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
onNodeFocus(id)
|
||||||
|
}, [id, onNodeFocus])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-[200px] rounded-lg border-2 ${
|
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||||
hasInvalidReference
|
hasInvalidReference
|
||||||
? 'border-orange-500 dark:border-orange-400'
|
? 'border-orange-500 dark:border-orange-400'
|
||||||
: 'border-blue-500 dark:border-blue-400'
|
: 'border-blue-500 dark:border-blue-400'
|
||||||
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={onNodeBlur}
|
||||||
>
|
>
|
||||||
|
{isLockedByOther && (
|
||||||
|
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||||
|
)}
|
||||||
|
{isLockedByOther && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||||
|
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||||
|
Being edited by {lockInfo.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
|||||||
import Combobox from '@/components/editor/Combobox'
|
import Combobox from '@/components/editor/Combobox'
|
||||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||||
|
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||||
|
|
||||||
type VariableNodeData = {
|
type VariableNodeData = {
|
||||||
variableName: string
|
variableName: string
|
||||||
@@ -15,7 +16,10 @@ type VariableNodeData = {
|
|||||||
|
|
||||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
const { variables, onAddVariable } = useEditorContext()
|
const { variables, onAddVariable, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||||
|
|
||||||
|
const lockInfo = nodeLocks.get(id)
|
||||||
|
const isLockedByOther = !!lockInfo
|
||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
@@ -109,14 +113,30 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
|||||||
// Filter operations based on selected variable type
|
// Filter operations based on selected variable type
|
||||||
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
onNodeFocus(id)
|
||||||
|
}, [id, onNodeFocus])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-[200px] rounded-lg border-2 ${
|
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||||
hasInvalidReference
|
hasInvalidReference
|
||||||
? 'border-orange-500 dark:border-orange-400'
|
? 'border-orange-500 dark:border-orange-400'
|
||||||
: 'border-orange-500 dark:border-orange-400'
|
: 'border-orange-500 dark:border-orange-400'
|
||||||
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={onNodeBlur}
|
||||||
>
|
>
|
||||||
|
{isLockedByOther && (
|
||||||
|
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||||
|
)}
|
||||||
|
{isLockedByOther && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||||
|
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||||
|
Being edited by {lockInfo.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Handle
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||||
|
|
||||||
|
export type AuditActionType =
|
||||||
|
| 'node_add'
|
||||||
|
| 'node_update'
|
||||||
|
| 'node_delete'
|
||||||
|
| 'edge_add'
|
||||||
|
| 'edge_update'
|
||||||
|
| 'edge_delete'
|
||||||
|
|
||||||
|
type PendingAuditEntry = {
|
||||||
|
actionType: AuditActionType
|
||||||
|
entityId: string
|
||||||
|
previousState: unknown | null
|
||||||
|
newState: unknown | null
|
||||||
|
timer: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 1000
|
||||||
|
|
||||||
|
export class AuditTrailRecorder {
|
||||||
|
private projectId: string
|
||||||
|
private userId: string
|
||||||
|
private previousNodes: Map<string, string> // node ID -> JSON string
|
||||||
|
private previousEdges: Map<string, string> // edge ID -> JSON string
|
||||||
|
private pending: Map<string, PendingAuditEntry> // entityId -> pending entry
|
||||||
|
private isDestroyed = false
|
||||||
|
|
||||||
|
constructor(projectId: string, userId: string) {
|
||||||
|
this.projectId = projectId
|
||||||
|
this.userId = userId
|
||||||
|
this.previousNodes = new Map()
|
||||||
|
this.previousEdges = new Map()
|
||||||
|
this.pending = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize with the current state (no audit entries are created for initial state) */
|
||||||
|
initialize(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||||
|
this.previousNodes.clear()
|
||||||
|
this.previousEdges.clear()
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
this.previousNodes.set(node.id, JSON.stringify(node))
|
||||||
|
})
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
this.previousEdges.set(edge.id, JSON.stringify(edge))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record changes by diffing current state against previous state */
|
||||||
|
recordNodeChanges(currentNodes: FlowchartNode[]): void {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
|
||||||
|
const currentMap = new Map<string, string>()
|
||||||
|
currentNodes.forEach((node) => {
|
||||||
|
currentMap.set(node.id, JSON.stringify(node))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Detect additions and updates
|
||||||
|
for (const [id, serialized] of currentMap) {
|
||||||
|
const previous = this.previousNodes.get(id)
|
||||||
|
if (!previous) {
|
||||||
|
// Node was added
|
||||||
|
this.scheduleWrite(id, 'node_add', null, JSON.parse(serialized))
|
||||||
|
} else if (previous !== serialized) {
|
||||||
|
// Node was updated
|
||||||
|
this.scheduleWrite(id, 'node_update', JSON.parse(previous), JSON.parse(serialized))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect deletions
|
||||||
|
for (const [id, serialized] of this.previousNodes) {
|
||||||
|
if (!currentMap.has(id)) {
|
||||||
|
this.scheduleWrite(id, 'node_delete', JSON.parse(serialized), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous state
|
||||||
|
this.previousNodes = currentMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record edge changes by diffing current state against previous state */
|
||||||
|
recordEdgeChanges(currentEdges: FlowchartEdge[]): void {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
|
||||||
|
const currentMap = new Map<string, string>()
|
||||||
|
currentEdges.forEach((edge) => {
|
||||||
|
currentMap.set(edge.id, JSON.stringify(edge))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Detect additions and updates
|
||||||
|
for (const [id, serialized] of currentMap) {
|
||||||
|
const previous = this.previousEdges.get(id)
|
||||||
|
if (!previous) {
|
||||||
|
// Edge was added
|
||||||
|
this.scheduleWrite(id, 'edge_add', null, JSON.parse(serialized))
|
||||||
|
} else if (previous !== serialized) {
|
||||||
|
// Edge was updated
|
||||||
|
this.scheduleWrite(id, 'edge_update', JSON.parse(previous), JSON.parse(serialized))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect deletions
|
||||||
|
for (const [id, serialized] of this.previousEdges) {
|
||||||
|
if (!currentMap.has(id)) {
|
||||||
|
this.scheduleWrite(id, 'edge_delete', JSON.parse(serialized), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous state
|
||||||
|
this.previousEdges = currentMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean up resources */
|
||||||
|
destroy(): void {
|
||||||
|
this.isDestroyed = true
|
||||||
|
// Flush all pending writes immediately
|
||||||
|
for (const [entityId, entry] of this.pending) {
|
||||||
|
clearTimeout(entry.timer)
|
||||||
|
this.writeEntry(entry)
|
||||||
|
this.pending.delete(entityId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleWrite(
|
||||||
|
entityId: string,
|
||||||
|
actionType: AuditActionType,
|
||||||
|
previousState: unknown | null,
|
||||||
|
newState: unknown | null
|
||||||
|
): void {
|
||||||
|
// If there's already a pending write for this entity, cancel it and update
|
||||||
|
const existing = this.pending.get(entityId)
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing.timer)
|
||||||
|
// For debounced updates, keep the original previousState but use latest newState
|
||||||
|
// For type changes (e.g., add then delete within 1s), use the new action type
|
||||||
|
const mergedPreviousState = existing.actionType === 'node_add' || existing.actionType === 'edge_add'
|
||||||
|
? null // If it was just added, there's no real previous state
|
||||||
|
: existing.previousState
|
||||||
|
const mergedActionType = this.mergeActionTypes(existing.actionType, actionType)
|
||||||
|
|
||||||
|
// If an entity was added and then deleted within the debounce window, skip entirely
|
||||||
|
if (mergedActionType === null) {
|
||||||
|
this.pending.delete(entityId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: PendingAuditEntry = {
|
||||||
|
actionType: mergedActionType,
|
||||||
|
entityId,
|
||||||
|
previousState: mergedPreviousState,
|
||||||
|
newState,
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
this.pending.delete(entityId)
|
||||||
|
this.writeEntry(entry)
|
||||||
|
}, DEBOUNCE_MS),
|
||||||
|
}
|
||||||
|
this.pending.set(entityId, entry)
|
||||||
|
} else {
|
||||||
|
const entry: PendingAuditEntry = {
|
||||||
|
actionType,
|
||||||
|
entityId,
|
||||||
|
previousState,
|
||||||
|
newState,
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
this.pending.delete(entityId)
|
||||||
|
this.writeEntry(entry)
|
||||||
|
}, DEBOUNCE_MS),
|
||||||
|
}
|
||||||
|
this.pending.set(entityId, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge two sequential action types for the same entity */
|
||||||
|
private mergeActionTypes(
|
||||||
|
first: AuditActionType,
|
||||||
|
second: AuditActionType
|
||||||
|
): AuditActionType | null {
|
||||||
|
// add + delete = no-op (entity never really existed)
|
||||||
|
if (
|
||||||
|
(first === 'node_add' && second === 'node_delete') ||
|
||||||
|
(first === 'edge_add' && second === 'edge_delete')
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// add + update = still an add (with the updated state)
|
||||||
|
if (
|
||||||
|
(first === 'node_add' && second === 'node_update') ||
|
||||||
|
(first === 'edge_add' && second === 'edge_update')
|
||||||
|
) {
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
// For all other cases, use the second (latest) action type
|
||||||
|
return second
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget write to Supabase */
|
||||||
|
private writeEntry(entry: PendingAuditEntry): void {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
|
||||||
|
const supabase = createClient()
|
||||||
|
supabase
|
||||||
|
.from('audit_trail')
|
||||||
|
.insert({
|
||||||
|
project_id: this.projectId,
|
||||||
|
user_id: this.userId,
|
||||||
|
action_type: entry.actionType,
|
||||||
|
entity_id: entry.entityId,
|
||||||
|
previous_state: entry.previousState,
|
||||||
|
new_state: entry.newState,
|
||||||
|
})
|
||||||
|
.then(({ error }) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('[AuditTrail] Failed to write audit entry:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import * as Y from 'yjs'
|
|||||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||||
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||||
|
|
||||||
const PERSIST_DEBOUNCE_MS = 2000
|
const PERSIST_DEBOUNCE_MS = 30_000 // 30s - DB persist is for crash recovery only; CRDT handles real-time sync
|
||||||
const BROADCAST_EVENT = 'yjs-update'
|
const BROADCAST_EVENT = 'yjs-update'
|
||||||
|
|
||||||
export type CRDTCallbacks = {
|
export type CRDTCallbacks = {
|
||||||
@@ -19,6 +19,7 @@ export class CRDTManager {
|
|||||||
private callbacks: CRDTCallbacks
|
private callbacks: CRDTCallbacks
|
||||||
private persistTimer: ReturnType<typeof setTimeout> | null = null
|
private persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private isApplyingRemote = false
|
private isApplyingRemote = false
|
||||||
|
private isSuppressed = false // suppress broadcast/persist during init or refresh
|
||||||
private isDestroyed = false
|
private isDestroyed = false
|
||||||
|
|
||||||
constructor(callbacks: CRDTCallbacks) {
|
constructor(callbacks: CRDTCallbacks) {
|
||||||
@@ -27,28 +28,27 @@ export class CRDTManager {
|
|||||||
this.edgesMap = this.doc.getMap('edges')
|
this.edgesMap = this.doc.getMap('edges')
|
||||||
this.callbacks = callbacks
|
this.callbacks = callbacks
|
||||||
|
|
||||||
// Listen for remote Yjs document changes
|
// Schedule persistence on local Yjs document changes
|
||||||
this.nodesMap.observe(() => {
|
this.nodesMap.observe(() => {
|
||||||
if (this.isApplyingRemote) return
|
if (this.isApplyingRemote || this.isSuppressed) return
|
||||||
this.notifyNodesChange()
|
|
||||||
this.schedulePersist()
|
this.schedulePersist()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.edgesMap.observe(() => {
|
this.edgesMap.observe(() => {
|
||||||
if (this.isApplyingRemote) return
|
if (this.isApplyingRemote || this.isSuppressed) return
|
||||||
this.notifyEdgesChange()
|
|
||||||
this.schedulePersist()
|
this.schedulePersist()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Broadcast local updates to other clients
|
// Broadcast local updates to other clients
|
||||||
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||||
if (origin === 'remote') return // Don't re-broadcast remote updates
|
if (origin === 'remote' || this.isSuppressed) return
|
||||||
this.broadcastUpdate(update)
|
this.broadcastUpdate(update)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Initialize the Yjs document from database state */
|
/** Initialize the Yjs document from database state */
|
||||||
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||||
|
this.isSuppressed = true
|
||||||
this.doc.transact(() => {
|
this.doc.transact(() => {
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
this.nodesMap.set(node.id, JSON.stringify(node))
|
this.nodesMap.set(node.id, JSON.stringify(node))
|
||||||
@@ -57,27 +57,70 @@ export class CRDTManager {
|
|||||||
this.edgesMap.set(edge.id, JSON.stringify(edge))
|
this.edgesMap.set(edge.id, JSON.stringify(edge))
|
||||||
})
|
})
|
||||||
}, 'init')
|
}, 'init')
|
||||||
|
this.isSuppressed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Connect to a Supabase Realtime channel for syncing updates */
|
/** Connect to a Supabase Realtime channel for outbound broadcasts */
|
||||||
connectChannel(channel: RealtimeChannel): void {
|
connectChannel(channel: RealtimeChannel): void {
|
||||||
this.channel = channel
|
this.channel = channel
|
||||||
|
// Broadcast full state so other clients merge any updates they missed
|
||||||
|
this.broadcastFullState()
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for broadcast updates from other clients
|
/** Broadcast the full Yjs document state to sync all connected clients */
|
||||||
channel.on('broadcast', { event: BROADCAST_EVENT }, (payload) => {
|
broadcastFullState(): void {
|
||||||
|
if (!this.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
|
if (this.isDestroyed) return
|
||||||
const data = payload.payload as { update?: number[] } | undefined
|
const update = new Uint8Array(updateData)
|
||||||
if (data?.update) {
|
|
||||||
const update = new Uint8Array(data.update)
|
|
||||||
this.isApplyingRemote = true
|
this.isApplyingRemote = true
|
||||||
Y.applyUpdate(this.doc, update, 'remote')
|
Y.applyUpdate(this.doc, update, 'remote')
|
||||||
this.isApplyingRemote = false
|
this.isApplyingRemote = false
|
||||||
// Notify React state of remote changes
|
// Notify React state of remote changes
|
||||||
this.notifyNodesChange()
|
this.notifyNodesChange()
|
||||||
this.notifyEdgesChange()
|
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 */
|
/** Apply local node changes to the Yjs document */
|
||||||
|
|||||||
@@ -8,26 +8,58 @@ export type PresenceUser = {
|
|||||||
displayName: string
|
displayName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CursorPosition = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RemoteCursor = {
|
||||||
|
userId: string
|
||||||
|
displayName: string
|
||||||
|
position: CursorPosition
|
||||||
|
lastUpdated: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeLock = {
|
||||||
|
nodeId: string
|
||||||
|
userId: string
|
||||||
|
displayName: string
|
||||||
|
lockedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
type RealtimeCallbacks = {
|
type RealtimeCallbacks = {
|
||||||
onConnectionStateChange: (state: ConnectionState) => void
|
onConnectionStateChange: (state: ConnectionState) => void
|
||||||
onPresenceSync?: (users: PresenceUser[]) => void
|
onPresenceSync?: (users: PresenceUser[]) => void
|
||||||
|
onPresenceJoin?: (user: PresenceUser) => void
|
||||||
|
onPresenceLeave?: (user: PresenceUser) => void
|
||||||
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
||||||
|
onCursorUpdate?: (cursor: RemoteCursor) => void
|
||||||
|
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
|
||||||
|
onCRDTUpdate?: (update: number[]) => void
|
||||||
|
onStateRefresh?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||||
const RECONNECT_BASE_DELAY_MS = 1000
|
const RECONNECT_BASE_DELAY_MS = 1000
|
||||||
const RECONNECT_MAX_DELAY_MS = 30_000
|
const RECONNECT_MAX_DELAY_MS = 30_000
|
||||||
|
const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
|
||||||
|
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection
|
||||||
|
const STALE_THRESHOLD_MS = 3 * 60_000 // 3 minutes hidden before forcing fresh reconnect
|
||||||
|
|
||||||
export class RealtimeConnection {
|
export class RealtimeConnection {
|
||||||
private channel: RealtimeChannel | null = null
|
private channel: RealtimeChannel | null = null
|
||||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private connectionTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private reconnectAttempts = 0
|
private reconnectAttempts = 0
|
||||||
private projectId: string
|
private projectId: string
|
||||||
private userId: string
|
private userId: string
|
||||||
private displayName: string
|
private displayName: string
|
||||||
private callbacks: RealtimeCallbacks
|
private callbacks: RealtimeCallbacks
|
||||||
private isDestroyed = false
|
private isDestroyed = false
|
||||||
|
private isPaused = false
|
||||||
|
private lastActivityTime = Date.now()
|
||||||
private supabase = createClient()
|
private supabase = createClient()
|
||||||
|
|
||||||
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
||||||
@@ -39,7 +71,36 @@ export class RealtimeConnection {
|
|||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
if (this.isDestroyed) return
|
if (this.isDestroyed) return
|
||||||
|
this.isPaused = false
|
||||||
|
this.lastActivityTime = Date.now()
|
||||||
this.callbacks.onConnectionStateChange('connecting')
|
this.callbacks.onConnectionStateChange('connecting')
|
||||||
|
this.resetInactivityTimer()
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
|
// Ensure the Supabase client has a valid auth session before connecting.
|
||||||
|
// On initial page load, the session may still be loading from cookies.
|
||||||
|
try {
|
||||||
|
const { data: { session } } = await this.supabase.auth.getSession()
|
||||||
|
if (!session && !this.isDestroyed) {
|
||||||
|
// No session yet - wait briefly and retry
|
||||||
|
this.scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Session check failed - proceed anyway, channel will handle auth errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDestroyed || this.isPaused) return
|
||||||
|
|
||||||
|
// Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry
|
||||||
|
this.connectionTimer = setTimeout(() => {
|
||||||
|
if (this.isDestroyed || this.isPaused) return
|
||||||
|
if (this.channel) {
|
||||||
|
this.supabase.removeChannel(this.channel)
|
||||||
|
this.channel = null
|
||||||
|
}
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}, CONNECTION_TIMEOUT_MS)
|
||||||
|
|
||||||
this.channel = this.supabase.channel(`project:${this.projectId}`, {
|
this.channel = this.supabase.channel(`project:${this.projectId}`, {
|
||||||
config: { presence: { key: this.userId } },
|
config: { presence: { key: this.userId } },
|
||||||
@@ -63,8 +124,62 @@ export class RealtimeConnection {
|
|||||||
this.callbacks.onPresenceSync?.(users)
|
this.callbacks.onPresenceSync?.(users)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.on('presence', { event: 'join' }, ({ newPresences }) => {
|
||||||
|
for (const presence of newPresences) {
|
||||||
|
const p = presence as { userId?: string; displayName?: string }
|
||||||
|
if (p.userId && p.userId !== this.userId) {
|
||||||
|
this.callbacks.onPresenceJoin?.({
|
||||||
|
userId: p.userId,
|
||||||
|
displayName: p.displayName || 'Anonymous',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
|
||||||
|
for (const presence of leftPresences) {
|
||||||
|
const p = presence as { userId?: string; displayName?: string }
|
||||||
|
if (p.userId && p.userId !== this.userId) {
|
||||||
|
this.callbacks.onPresenceLeave?.({
|
||||||
|
userId: p.userId,
|
||||||
|
displayName: p.displayName || 'Anonymous',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||||
|
if (payload.userId === this.userId) return
|
||||||
|
this.callbacks.onCursorUpdate?.({
|
||||||
|
userId: payload.userId,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
position: { x: payload.x, y: payload.y },
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.on('broadcast', { event: 'node-lock' }, ({ payload }) => {
|
||||||
|
if (payload.userId === this.userId) return
|
||||||
|
if (payload.nodeId) {
|
||||||
|
this.callbacks.onNodeLockUpdate?.({
|
||||||
|
nodeId: payload.nodeId,
|
||||||
|
userId: payload.userId,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
lockedAt: payload.lockedAt,
|
||||||
|
}, payload.userId)
|
||||||
|
} else {
|
||||||
|
// null nodeId means unlock
|
||||||
|
this.callbacks.onNodeLockUpdate?.(null, payload.userId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('broadcast', { event: 'yjs-update' }, ({ payload }) => {
|
||||||
|
if (payload?.update) {
|
||||||
|
this.callbacks.onCRDTUpdate?.(payload.update)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('broadcast', { event: 'state-refresh' }, () => {
|
||||||
|
this.callbacks.onStateRefresh?.()
|
||||||
|
})
|
||||||
.subscribe(async (status) => {
|
.subscribe(async (status) => {
|
||||||
if (this.isDestroyed) return
|
if (this.isDestroyed) return
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
if (status === 'SUBSCRIBED') {
|
if (status === 'SUBSCRIBED') {
|
||||||
this.reconnectAttempts = 0
|
this.reconnectAttempts = 0
|
||||||
@@ -84,15 +199,24 @@ export class RealtimeConnection {
|
|||||||
this.callbacks.onConnectionStateChange('reconnecting')
|
this.callbacks.onConnectionStateChange('reconnecting')
|
||||||
this.scheduleReconnect()
|
this.scheduleReconnect()
|
||||||
} else if (status === 'CLOSED') {
|
} else if (status === 'CLOSED') {
|
||||||
|
if (!this.isPaused) {
|
||||||
|
// Unexpected close - attempt to reconnect
|
||||||
|
this.callbacks.onConnectionStateChange('reconnecting')
|
||||||
|
this.scheduleReconnect()
|
||||||
|
} else {
|
||||||
this.callbacks.onConnectionStateChange('disconnected')
|
this.callbacks.onConnectionStateChange('disconnected')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
this.isDestroyed = true
|
this.isDestroyed = true
|
||||||
|
this.isPaused = false
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
this.clearReconnectTimer()
|
this.clearReconnectTimer()
|
||||||
|
this.clearInactivityTimer()
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
if (this.channel) {
|
if (this.channel) {
|
||||||
await this.deleteSession()
|
await this.deleteSession()
|
||||||
@@ -103,10 +227,133 @@ export class RealtimeConnection {
|
|||||||
this.callbacks.onConnectionStateChange('disconnected')
|
this.callbacks.onConnectionStateChange('disconnected')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the connection (e.g. on inactivity or tab hidden).
|
||||||
|
* Unlike disconnect(), this allows resuming later.
|
||||||
|
*/
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
if (this.isDestroyed || this.isPaused) return
|
||||||
|
this.isPaused = true
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.clearInactivityTimer()
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
|
if (this.channel) {
|
||||||
|
await this.deleteSession()
|
||||||
|
this.supabase.removeChannel(this.channel)
|
||||||
|
this.channel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onConnectionStateChange('disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume the connection after it was paused.
|
||||||
|
* Re-establishes the channel and presence.
|
||||||
|
*/
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
if (this.isDestroyed || !this.isPaused) return
|
||||||
|
this.isPaused = false
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify that user activity has occurred, resetting the inactivity timer.
|
||||||
|
* If the connection was paused due to inactivity, it will resume.
|
||||||
|
*/
|
||||||
|
notifyActivity(): void {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
this.lastActivityTime = Date.now()
|
||||||
|
this.resetInactivityTimer()
|
||||||
|
|
||||||
|
if (this.isPaused) {
|
||||||
|
this.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the tab becomes visible again. If the tab was hidden for a
|
||||||
|
* significant period, force a fresh reconnect to handle stale WebSockets.
|
||||||
|
*/
|
||||||
|
notifyVisibilityResumed(): void {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
const now = Date.now()
|
||||||
|
const hiddenDuration = now - this.lastActivityTime
|
||||||
|
this.lastActivityTime = now
|
||||||
|
this.resetInactivityTimer()
|
||||||
|
|
||||||
|
if (this.isPaused) {
|
||||||
|
this.resume()
|
||||||
|
} else if (hiddenDuration > STALE_THRESHOLD_MS && this.channel) {
|
||||||
|
this.forceReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a fresh reconnect by tearing down the current channel and reconnecting.
|
||||||
|
*/
|
||||||
|
private async forceReconnect(): Promise<void> {
|
||||||
|
if (this.isDestroyed || this.isPaused) return
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.clearReconnectTimer()
|
||||||
|
this.clearConnectionTimer()
|
||||||
|
|
||||||
|
if (this.channel) {
|
||||||
|
this.supabase.removeChannel(this.channel)
|
||||||
|
this.channel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
await this.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
getIsPaused(): boolean {
|
||||||
|
return this.isPaused
|
||||||
|
}
|
||||||
|
|
||||||
getChannel(): RealtimeChannel | null {
|
getChannel(): RealtimeChannel | null {
|
||||||
return this.channel
|
return this.channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broadcastCursor(position: CursorPosition): void {
|
||||||
|
if (!this.channel) return
|
||||||
|
this.channel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'cursor',
|
||||||
|
payload: {
|
||||||
|
userId: this.userId,
|
||||||
|
displayName: this.displayName,
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastStateRefresh(): void {
|
||||||
|
if (!this.channel) return
|
||||||
|
this.channel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'state-refresh',
|
||||||
|
payload: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastNodeLock(nodeId: string | null): void {
|
||||||
|
if (!this.channel) return
|
||||||
|
this.channel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'node-lock',
|
||||||
|
payload: {
|
||||||
|
userId: this.userId,
|
||||||
|
displayName: this.displayName,
|
||||||
|
nodeId,
|
||||||
|
lockedAt: Date.now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async createSession(): Promise<void> {
|
private async createSession(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.supabase.from('collaboration_sessions').upsert(
|
await this.supabase.from('collaboration_sessions').upsert(
|
||||||
@@ -139,19 +386,44 @@ export class RealtimeConnection {
|
|||||||
|
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
|
let consecutiveFailures = 0
|
||||||
this.heartbeatTimer = setInterval(async () => {
|
this.heartbeatTimer = setInterval(async () => {
|
||||||
if (this.isDestroyed) {
|
if (this.isDestroyed || this.isPaused) {
|
||||||
this.stopHeartbeat()
|
this.stopHeartbeat()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the channel is still in a healthy state
|
||||||
|
if (this.channel) {
|
||||||
|
const state = (this.channel as unknown as { state?: string }).state
|
||||||
|
if (state && state !== 'joined' && state !== 'joining') {
|
||||||
|
// Channel is in an unhealthy state - trigger reconnect
|
||||||
|
this.callbacks.onConnectionStateChange('reconnecting')
|
||||||
|
this.supabase.removeChannel(this.channel)
|
||||||
|
this.channel = null
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.supabase
|
await this.supabase
|
||||||
.from('collaboration_sessions')
|
.from('collaboration_sessions')
|
||||||
.update({ last_heartbeat: new Date().toISOString() })
|
.update({ last_heartbeat: new Date().toISOString() })
|
||||||
.eq('project_id', this.projectId)
|
.eq('project_id', this.projectId)
|
||||||
.eq('user_id', this.userId)
|
.eq('user_id', this.userId)
|
||||||
|
consecutiveFailures = 0
|
||||||
} catch {
|
} catch {
|
||||||
// Heartbeat failure is non-critical
|
consecutiveFailures++
|
||||||
|
// If heartbeat fails 3 times in a row, the connection is likely dead
|
||||||
|
if (consecutiveFailures >= 3 && this.channel) {
|
||||||
|
this.callbacks.onConnectionStateChange('reconnecting')
|
||||||
|
this.supabase.removeChannel(this.channel)
|
||||||
|
this.channel = null
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, HEARTBEAT_INTERVAL_MS)
|
}, HEARTBEAT_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
@@ -192,4 +464,28 @@ export class RealtimeConnection {
|
|||||||
this.reconnectTimer = null
|
this.reconnectTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resetInactivityTimer(): void {
|
||||||
|
this.clearInactivityTimer()
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
this.inactivityTimer = setTimeout(() => {
|
||||||
|
if (!this.isDestroyed && !this.isPaused) {
|
||||||
|
this.pause()
|
||||||
|
}
|
||||||
|
}, INACTIVITY_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearInactivityTimer(): void {
|
||||||
|
if (this.inactivityTimer) {
|
||||||
|
clearTimeout(this.inactivityTimer)
|
||||||
|
this.inactivityTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearConnectionTimer(): void {
|
||||||
|
if (this.connectionTimer) {
|
||||||
|
clearTimeout(this.connectionTimer)
|
||||||
|
this.connectionTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- Migration: Fix infinite recursion in RLS policies
|
||||||
|
-- Problem: Self-referencing policies and circular dependencies between
|
||||||
|
-- projects <-> project_collaborators cause infinite recursion.
|
||||||
|
-- Solution: Use SECURITY DEFINER functions to bypass RLS for permission checks.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- HELPER FUNCTIONS (SECURITY DEFINER bypasses RLS)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Check if the current user is an admin
|
||||||
|
CREATE OR REPLACE FUNCTION is_admin()
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
AND is_admin = true
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Check if the current user is the owner of a project
|
||||||
|
CREATE OR REPLACE FUNCTION is_project_owner(p_project_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM projects
|
||||||
|
WHERE id = p_project_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Check if the current user is a collaborator on a project (any role)
|
||||||
|
CREATE OR REPLACE FUNCTION is_project_collaborator(p_project_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM project_collaborators
|
||||||
|
WHERE project_id = p_project_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Check if the current user is an editor or owner collaborator on a project
|
||||||
|
CREATE OR REPLACE FUNCTION is_project_editor(p_project_id uuid)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM project_collaborators
|
||||||
|
WHERE project_id = p_project_id
|
||||||
|
AND user_id = auth.uid()
|
||||||
|
AND role IN ('owner', 'editor')
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX PROFILES POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the problematic admin policy (self-references profiles table)
|
||||||
|
DROP POLICY IF EXISTS "Admins can view all profiles" ON profiles;
|
||||||
|
|
||||||
|
-- Recreate using the helper function
|
||||||
|
CREATE POLICY "Admins can view all profiles"
|
||||||
|
ON profiles
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_admin());
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX PROJECT_COLLABORATORS POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the problematic SELECT policy (self-references project_collaborators)
|
||||||
|
DROP POLICY IF EXISTS "Users can view collaborators for their projects" ON project_collaborators;
|
||||||
|
|
||||||
|
-- Recreate without self-reference: user is either the collaborator row's user,
|
||||||
|
-- the project owner, or already a collaborator on that project
|
||||||
|
CREATE POLICY "Users can view collaborators for their projects"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
auth.uid() = user_id
|
||||||
|
OR is_project_owner(project_id)
|
||||||
|
OR is_project_collaborator(project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Drop and recreate INSERT/UPDATE/DELETE policies to use helper functions
|
||||||
|
DROP POLICY IF EXISTS "Owners can add collaborators" ON project_collaborators;
|
||||||
|
CREATE POLICY "Owners can add collaborators"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (is_project_owner(project_id));
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Owners can update collaborators" ON project_collaborators;
|
||||||
|
CREATE POLICY "Owners can update collaborators"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR UPDATE
|
||||||
|
USING (is_project_owner(project_id))
|
||||||
|
WITH CHECK (is_project_owner(project_id));
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Owners can remove collaborators" ON project_collaborators;
|
||||||
|
CREATE POLICY "Owners can remove collaborators"
|
||||||
|
ON project_collaborators
|
||||||
|
FOR DELETE
|
||||||
|
USING (is_project_owner(project_id));
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX COLLABORATION_SESSIONS POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the SELECT policy that cross-references both projects and project_collaborators
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can view sessions" ON collaboration_sessions;
|
||||||
|
|
||||||
|
CREATE POLICY "Collaborators can view sessions"
|
||||||
|
ON collaboration_sessions
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_project_owner(project_id)
|
||||||
|
OR is_project_collaborator(project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX AUDIT_TRAIL POLICIES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can view audit trail" ON audit_trail;
|
||||||
|
CREATE POLICY "Collaborators can view audit trail"
|
||||||
|
ON audit_trail
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
is_project_owner(project_id)
|
||||||
|
OR is_project_collaborator(project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can write audit entries" ON audit_trail;
|
||||||
|
CREATE POLICY "Collaborators can write audit entries"
|
||||||
|
ON audit_trail
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
auth.uid() = user_id
|
||||||
|
AND (
|
||||||
|
is_project_owner(project_id)
|
||||||
|
OR is_project_editor(project_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FIX PROJECTS POLICIES (collaborator access)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Drop the policies that query project_collaborators (creating circular deps)
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can view shared projects" ON projects;
|
||||||
|
CREATE POLICY "Collaborators can view shared projects"
|
||||||
|
ON projects
|
||||||
|
FOR SELECT
|
||||||
|
USING (is_project_collaborator(id));
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Collaborators can update shared projects" ON projects;
|
||||||
|
CREATE POLICY "Collaborators can update shared projects"
|
||||||
|
ON projects
|
||||||
|
FOR UPDATE
|
||||||
|
USING (is_project_editor(id))
|
||||||
|
WITH CHECK (is_project_editor(id));
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Migration: Allow authenticated users to look up profiles for sharing
|
||||||
|
-- Problem: The profiles RLS policies only allow users to see their own profile
|
||||||
|
-- (or admins to see all). This prevents the sharing feature from finding
|
||||||
|
-- users by email since the query is blocked by RLS.
|
||||||
|
-- Solution: Add a SELECT policy allowing any authenticated user to view profiles.
|
||||||
|
|
||||||
|
CREATE POLICY "Authenticated users can view profiles"
|
||||||
|
ON profiles
|
||||||
|
FOR SELECT
|
||||||
|
USING (auth.uid() IS NOT NULL);
|
||||||
Reference in New Issue
Block a user