diff --git a/package-lock.json b/package-lock.json index 1e138bc..356f8a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "next": "16.1.4", "react": "19.2.3", "react-dom": "19.2.3", - "reactflow": "^11.11.4" + "reactflow": "^11.11.4", + "yjs": "^13.6.29" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -4975,6 +4976,16 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -5130,6 +5141,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -7186,6 +7218,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yjs": { + "version": "13.6.29", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz", + "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 839d947..ca26b26 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "next": "16.1.4", "react": "19.2.3", "react-dom": "19.2.3", - "reactflow": "^11.11.4" + "reactflow": "^11.11.4", + "yjs": "^13.6.29" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/prd.json b/prd.json index 7a27321..378ea27 100644 --- a/prd.json +++ b/prd.json @@ -216,7 +216,7 @@ "Verify in browser using dev-browser skill" ], "priority": 12, - "passes": false, + "passes": true, "notes": "Dependencies: US-058, US-059, US-060, US-061" }, { @@ -232,7 +232,7 @@ "Typecheck passes" ], "priority": 13, - "passes": false, + "passes": true, "notes": "" }, { @@ -251,7 +251,7 @@ "Verify in browser using dev-browser skill" ], "priority": 14, - "passes": false, + "passes": true, "notes": "Dependencies: US-043" }, { @@ -269,7 +269,7 @@ "Verify in browser using dev-browser skill" ], "priority": 15, - "passes": false, + "passes": true, "notes": "Dependencies: US-043" }, { @@ -287,7 +287,7 @@ "Verify in browser using dev-browser skill" ], "priority": 16, - "passes": false, + "passes": true, "notes": "Dependencies: US-045" }, { @@ -306,7 +306,7 @@ "Typecheck passes" ], "priority": 17, - "passes": false, + "passes": true, "notes": "Dependencies: US-045" }, { diff --git a/progress.txt b/progress.txt index d439bdb..023908d 100644 --- a/progress.txt +++ b/progress.txt @@ -43,6 +43,23 @@ - `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions. - React Compiler lint forbids `setState` in effects and reading `useRef().current` during render. Use `useState(() => computeValue())` lazy initializer pattern for one-time initialization logic. - For detecting legacy data shape (pre-migration), pass a flag from the server component (page.tsx) to the client component, since only the server reads raw DB data. +- Collaboration tables: `project_collaborators` (roles), `collaboration_sessions` (presence), `audit_trail` (history) — all with RLS scoped by project ownership or collaborator membership +- RLS pattern for shared resources: check `projects.user_id = auth.uid()` OR `project_collaborators.user_id = auth.uid()` to cover both owners and collaborators +- `RealtimeConnection` class at `src/lib/collaboration/realtime.ts` manages Supabase Realtime channel lifecycle (connect, heartbeat, reconnect, disconnect). Instantiate with (projectId, userId, callbacks). +- FlowchartEditor receives `userId` prop from page.tsx server component for collaboration features +- Toolbar accepts optional `connectionState` prop to show green/yellow/red connection indicator +- `collaboration_sessions` table has UNIQUE(project_id, user_id) constraint to support upsert-based session management +- Server actions for project-specific operations go in `src/app/editor/[projectId]/actions.ts` — use `'use server'` directive and return `{ success: boolean; error?: string }` pattern +- Editor page.tsx supports both owner and collaborator access: first checks ownership, then falls back to `project_collaborators` lookup. Pass `isOwner` prop to client component. +- `ShareModal` at `src/components/editor/ShareModal.tsx` manages collaborator invites/roles/removal via server actions. Only owners see invite form. +- Dashboard shared projects use Supabase join query: `project_collaborators.select('role, projects(id, name, updated_at)')` to fetch projects shared with the user +- `ProjectCard` supports optional `shared` and `sharedRole` props — when `shared=true`, hide edit/delete buttons and show role badge instead +- `PresenceAvatars` at `src/components/editor/PresenceAvatars.tsx` renders connected collaborator avatars. Receives `PresenceUser[]` from `RealtimeConnection.onPresenceSync`. +- `RealtimeConnection` constructor takes `(projectId, userId, displayName, callbacks)` — `displayName` is broadcast via Supabase Realtime presence tracking +- User color for presence is derived from a hash of their userId, ensuring consistency across sessions. Use `getUserColor(userId)` pattern from PresenceAvatars. +- `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates. +- CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops. +- For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive. --- diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 8b153c5..89bceb4 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import NewProjectButton from '@/components/NewProjectButton' import ProjectList from '@/components/ProjectList' +import ProjectCard from '@/components/ProjectCard' export default async function DashboardPage() { const supabase = await createClient() @@ -19,6 +20,21 @@ export default async function DashboardPage() { .eq('user_id', user.id) .order('updated_at', { ascending: false }) + // Fetch shared projects (projects where this user is a collaborator) + const { data: collaborations } = await supabase + .from('project_collaborators') + .select('role, projects(id, name, updated_at)') + .eq('user_id', user.id) + + const sharedProjects = (collaborations || []) + .filter((c) => c.projects) + .map((c) => ({ + ...(c.projects as unknown as { id: string; name: string; updated_at: string }), + shared: true, + role: c.role, + })) + .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()) + if (error) { return (
@@ -44,6 +60,26 @@ export default async function DashboardPage() {
+ + {sharedProjects.length > 0 && ( +
+

+ Shared with me +

+
+ {sharedProjects.map((project) => ( + + ))} +
+
+ )} ) } diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 51a8327..78a4d89 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,11 +1,6 @@ 'use client' -<<<<<<< HEAD -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useRouter } from 'next/navigation' -======= import React, { useCallback, useMemo, useState } from 'react' ->>>>>>> ralph/collaboration-and-character-variables import ReactFlow, { Background, BackgroundVariant, @@ -32,37 +27,16 @@ import { createClient } from '@/lib/supabase/client' import DialogueNode from '@/components/editor/nodes/DialogueNode' import ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' -<<<<<<< HEAD -import ConditionalEdge from '@/components/editor/edges/ConditionalEdge' -import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' -import ConditionEditor from '@/components/editor/ConditionEditor' -import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' - -// LocalStorage key prefix for draft saves -const DRAFT_KEY_PREFIX = 'vnwrite-draft-' - -// Debounce delay in ms -const AUTOSAVE_DEBOUNCE_MS = 1000 - -type ContextMenuState = { - x: number - y: number - type: ContextMenuType - nodeId?: string - edgeId?: string -} | null - -type ConditionEditorState = { - edgeId: string - condition?: Condition -} | null -======= import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' import ConditionEditor from '@/components/editor/ConditionEditor' +import ExportValidationModal, { type ValidationIssue } from '@/components/editor/ExportValidationModal' import { EditorProvider } from '@/components/editor/EditorContext' import Toast from '@/components/Toast' +import { RealtimeConnection, type ConnectionState, type PresenceUser } from '@/lib/collaboration/realtime' +import { CRDTManager } from '@/lib/collaboration/crdt' +import { createClient } from '@/lib/supabase/client' +import ShareModal from '@/components/editor/ShareModal' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' ->>>>>>> ralph/collaboration-and-character-variables type FlowchartEditorProps = { projectId: string @@ -97,314 +71,6 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { })) } -<<<<<<< HEAD -// Convert React Flow Node type back to our FlowchartNode type -function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] { - return nodes.map((node) => ({ - id: node.id, - type: node.type as 'dialogue' | 'choice' | 'variable', - position: node.position, - data: node.data, - })) as FlowchartNode[] -} - -// Convert React Flow Edge type back to our FlowchartEdge type -function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] { - return edges.map((edge) => ({ - id: edge.id, - source: edge.source, - sourceHandle: edge.sourceHandle ?? undefined, - target: edge.target, - targetHandle: edge.targetHandle ?? undefined, - data: edge.data, - })) -} - -// Get LocalStorage key for a project -function getDraftKey(projectId: string): string { - return `${DRAFT_KEY_PREFIX}${projectId}` -} - -// Save draft to LocalStorage -function saveDraft(projectId: string, data: FlowchartData): void { - try { - localStorage.setItem(getDraftKey(projectId), JSON.stringify(data)) - } catch (error) { - console.error('Failed to save draft to LocalStorage:', error) - } -} - -// Load draft from LocalStorage -function loadDraft(projectId: string): FlowchartData | null { - try { - const draft = localStorage.getItem(getDraftKey(projectId)) - if (!draft) return null - return JSON.parse(draft) as FlowchartData - } catch (error) { - console.error('Failed to load draft from LocalStorage:', error) - return null - } -} - -// Clear draft from LocalStorage -export function clearDraft(projectId: string): void { - try { - localStorage.removeItem(getDraftKey(projectId)) - } catch (error) { - console.error('Failed to clear draft from LocalStorage:', error) - } -} - -// Compare two FlowchartData objects for equality -function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean { - return JSON.stringify(a) === JSON.stringify(b) -} - -// Validate imported flowchart data structure -function isValidFlowchartData(data: unknown): data is FlowchartData { - if (!data || typeof data !== 'object') return false - const obj = data as Record - if (!Array.isArray(obj.nodes)) return false - if (!Array.isArray(obj.edges)) return false - return true -} - -// Ren'Py export types -type RenpyDialogueNode = { - type: 'dialogue' - speaker: string - text: string - next?: string - condition?: Condition -} - -type RenpyMenuChoice = { - label: string - next?: string - condition?: Condition -} - -type RenpyMenuNode = { - type: 'menu' - prompt: string - choices: RenpyMenuChoice[] -} - -type RenpyVariableNode = { - type: 'variable' - name: string - operation: 'set' | 'add' | 'subtract' - value: number - next?: string - condition?: Condition -} - -type RenpyNode = RenpyDialogueNode | RenpyMenuNode | RenpyVariableNode - -type RenpyExport = { - projectName: string - exportedAt: string - sections: Record -} - -// Find the first node (node with no incoming edges) -function findFirstNode(nodes: FlowchartNode[], edges: FlowchartEdge[]): FlowchartNode | null { - const targetIds = new Set(edges.map((e) => e.target)) - const startNodes = nodes.filter((n) => !targetIds.has(n.id)) - // Return the first start node, or the first node if all have incoming edges - return startNodes[0] || nodes[0] || null -} - -// Get outgoing edge from a node (for dialogue and variable nodes) -function getOutgoingEdge(nodeId: string, edges: FlowchartEdge[], sourceHandle?: string): FlowchartEdge | undefined { - return edges.find((e) => e.source === nodeId && (sourceHandle === undefined || e.sourceHandle === sourceHandle)) -} - -// Get all outgoing edges from a node (for choice nodes) -function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] { - return edges.filter((e) => e.source === nodeId) -} - -// Convert flowchart to Ren'Py format using graph traversal -function convertToRenpyFormat( - nodes: FlowchartNode[], - edges: FlowchartEdge[], - projectName: string -): RenpyExport { - const nodeMap = new Map(nodes.map((n) => [n.id, n])) - const visited = new Set() - const sections: Record = {} - let currentSectionName = 'start' - let currentSection: RenpyNode[] = [] - - // Helper to get or create a label for a node - const nodeLabels = new Map() - let labelCounter = 0 - - function getNodeLabel(nodeId: string): string { - if (!nodeLabels.has(nodeId)) { - const node = nodeMap.get(nodeId) - if (node?.type === 'dialogue' && node.data.speaker) { - // Use speaker name as part of label if available - nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`) - } else { - nodeLabels.set(nodeId, `section_${labelCounter++}`) - } - } - return nodeLabels.get(nodeId)! - } - - // Process a node and its successors - function processNode(nodeId: string): void { - if (visited.has(nodeId)) return - visited.add(nodeId) - - const node = nodeMap.get(nodeId) - if (!node) return - - if (node.type === 'dialogue') { - const outgoingEdge = getOutgoingEdge(nodeId, edges) - const renpyNode: RenpyDialogueNode = { - type: 'dialogue', - speaker: node.data.speaker || '', - text: node.data.text, - } - - if (outgoingEdge) { - // Check if target node is already visited (creates a jump) - if (visited.has(outgoingEdge.target)) { - renpyNode.next = getNodeLabel(outgoingEdge.target) - } else { - renpyNode.next = outgoingEdge.target - } - if (outgoingEdge.data?.condition) { - renpyNode.condition = outgoingEdge.data.condition - } - } - - currentSection.push(renpyNode) - - // Process next node if not visited - if (outgoingEdge && !visited.has(outgoingEdge.target)) { - processNode(outgoingEdge.target) - } - } else if (node.type === 'choice') { - const outgoingEdges = getOutgoingEdges(nodeId, edges) - - // Map options to their corresponding edges - const choices: RenpyMenuChoice[] = node.data.options.map((option, index) => { - // Find edge for this option handle - const optionEdge = outgoingEdges.find((e) => e.sourceHandle === `option-${index}`) - const choice: RenpyMenuChoice = { - label: option.label || `Option ${index + 1}`, - } - - if (optionEdge) { - // If target is visited, use label; otherwise use target id - if (visited.has(optionEdge.target)) { - choice.next = getNodeLabel(optionEdge.target) - } else { - choice.next = optionEdge.target - } - if (optionEdge.data?.condition) { - choice.condition = optionEdge.data.condition - } - } - - // Per-option condition (visibility condition) takes priority over edge condition - if (option.condition) { - choice.condition = option.condition - } - - return choice - }) - - const renpyNode: RenpyMenuNode = { - type: 'menu', - prompt: node.data.prompt || '', - choices, - } - - currentSection.push(renpyNode) - - // Save current section before processing branches - sections[currentSectionName] = currentSection - - // Process each branch in a new section - for (const choice of choices) { - if (choice.next && !visited.has(choice.next)) { - const targetNode = nodeMap.get(choice.next) - if (targetNode) { - // Start new section for this branch - currentSectionName = getNodeLabel(choice.next) - currentSection = [] - processNode(choice.next) - if (currentSection.length > 0) { - sections[currentSectionName] = currentSection - } - } - } - } - } else if (node.type === 'variable') { - const outgoingEdge = getOutgoingEdge(nodeId, edges) - const renpyNode: RenpyVariableNode = { - type: 'variable', - name: node.data.variableName, - operation: node.data.operation, - value: node.data.value, - } - - if (outgoingEdge) { - if (visited.has(outgoingEdge.target)) { - renpyNode.next = getNodeLabel(outgoingEdge.target) - } else { - renpyNode.next = outgoingEdge.target - } - if (outgoingEdge.data?.condition) { - renpyNode.condition = outgoingEdge.data.condition - } - } - - currentSection.push(renpyNode) - - if (outgoingEdge && !visited.has(outgoingEdge.target)) { - processNode(outgoingEdge.target) - } - } - } - - // Find and process starting from the first node - const firstNode = findFirstNode(nodes, edges) - if (firstNode) { - processNode(firstNode.id) - // Save the final section if it has content - if (currentSection.length > 0 && !sections[currentSectionName]) { - sections[currentSectionName] = currentSection - } - } - - // Replace node IDs in next fields with proper labels - for (const sectionNodes of Object.values(sections)) { - for (const renpyNode of sectionNodes) { - if (renpyNode.type === 'dialogue' || renpyNode.type === 'variable') { - if (renpyNode.next && nodeLabels.has(renpyNode.next)) { - renpyNode.next = nodeLabels.get(renpyNode.next) - } - } else if (renpyNode.type === 'menu') { - for (const choice of renpyNode.choices) { - if (choice.next && nodeLabels.has(choice.next)) { - choice.next = nodeLabels.get(choice.next) - } - } - } - } - } - - return { - projectName, - exportedAt: new Date().toISOString(), - sections, -======= const RANDOM_COLORS = [ '#EF4444', '#F97316', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', @@ -542,16 +208,11 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { nodes: migratedNodes, edges: migratedEdges, toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`, ->>>>>>> ralph/collaboration-and-character-variables } } // Inner component that uses useReactFlow hook -<<<<<<< HEAD -function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) { -======= function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) { ->>>>>>> ralph/collaboration-and-character-variables // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -619,8 +280,104 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch const [characters, setCharacters] = useState(migratedData.characters) const [variables, setVariables] = useState(migratedData.variables) const [showSettings, setShowSettings] = useState(false) + const [showShare, setShowShare] = useState(false) const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [toastMessage, setToastMessage] = useState(migratedData.toastMessage) + const [validationIssues, setValidationIssues] = useState(null) + const [warningNodeIds, setWarningNodeIds] = useState>(new Set()) + const [connectionState, setConnectionState] = useState('disconnected') + const [presenceUsers, setPresenceUsers] = useState([]) + const realtimeRef = useRef(null) + const crdtRef = useRef(null) + const isRemoteUpdateRef = useRef(false) + + // Initialize CRDT manager and connect to Supabase Realtime channel on mount + useEffect(() => { + const supabase = createClient() + + const crdtManager = new CRDTManager({ + onNodesChange: (crdtNodes: FlowchartNode[]) => { + isRemoteUpdateRef.current = true + setNodes(toReactFlowNodes(crdtNodes)) + isRemoteUpdateRef.current = false + }, + onEdgesChange: (crdtEdges: FlowchartEdge[]) => { + isRemoteUpdateRef.current = true + setEdges(toReactFlowEdges(crdtEdges)) + isRemoteUpdateRef.current = false + }, + onPersist: async (persistNodes: FlowchartNode[], persistEdges: FlowchartEdge[]) => { + try { + await supabase + .from('projects') + .update({ + flowchart_data: { + nodes: persistNodes, + edges: persistEdges, + characters, + variables, + }, + }) + .eq('id', projectId) + } catch { + // Persistence failure is non-critical; will retry on next change + } + }, + }) + + // Initialize CRDT document from initial data + crdtManager.initializeFromData(migratedData.nodes, migratedData.edges) + crdtRef.current = crdtManager + + const connection = new RealtimeConnection(projectId, userId, userDisplayName, { + onConnectionStateChange: setConnectionState, + onPresenceSync: setPresenceUsers, + onChannelSubscribed: (channel) => { + crdtManager.connectChannel(channel) + }, + }) + realtimeRef.current = connection + connection.connect() + + return () => { + connection.disconnect() + realtimeRef.current = null + crdtManager.destroy() + crdtRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId, userId, userDisplayName]) + + // Sync local React Flow state changes to CRDT (skip remote-originated updates) + const nodesForCRDT = useMemo(() => { + return nodes.map((node) => ({ + id: node.id, + type: node.type as 'dialogue' | 'choice' | 'variable', + position: node.position, + data: node.data, + })) as FlowchartNode[] + }, [nodes]) + + const edgesForCRDT = useMemo(() => { + return edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle, + target: edge.target, + targetHandle: edge.targetHandle, + data: edge.data, + })) as FlowchartEdge[] + }, [edges]) + + useEffect(() => { + if (isRemoteUpdateRef.current) return + crdtRef.current?.updateNodes(nodesForCRDT) + }, [nodesForCRDT]) + + useEffect(() => { + if (isRemoteUpdateRef.current) return + crdtRef.current?.updateEdges(edgesForCRDT) + }, [edgesForCRDT]) const handleAddCharacter = useCallback( (name: string, color: string): string => { @@ -1047,6 +804,19 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch [setEdges] ) + // Apply warning styles to nodes with undefined references + const styledNodes = useMemo( + () => + warningNodeIds.size === 0 + ? nodes + : nodes.map((node) => + warningNodeIds.has(node.id) + ? { ...node, className: 'export-warning-node' } + : node + ), + [nodes, warningNodeIds] + ) + // Get the selected edge's condition data const selectedEdge = useMemo( () => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null), @@ -1064,10 +834,13 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch onExport={handleExport} onImport={handleImport} onProjectSettings={() => setShowSettings(true)} + onShare={() => setShowShare(true)} + connectionState={connectionState} + presenceUsers={presenceUsers} />
)} + {showShare && ( + setShowShare(false)} + /> + )} {selectedEdge && ( setSelectedEdgeId(null)} /> )} + {validationIssues && ( + + )} {toastMessage && ( { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { success: false, error: 'Not authenticated' } + } + + // Verify user owns the project + const { data: project } = await supabase + .from('projects') + .select('id, user_id') + .eq('id', projectId) + .single() + + if (!project) { + return { success: false, error: 'Project not found' } + } + + const isOwner = project.user_id === user.id + + // Check if user is a collaborator + if (!isOwner) { + const { data: collab } = await supabase + .from('project_collaborators') + .select('id') + .eq('project_id', projectId) + .eq('user_id', user.id) + .single() + + if (!collab) { + return { success: false, error: 'Access denied' } + } + } + + // Fetch collaborators with profile info + const { data: collaborators, error } = await supabase + .from('project_collaborators') + .select('id, user_id, role, invited_at, accepted_at, profiles(display_name, email)') + .eq('project_id', projectId) + + if (error) { + return { success: false, error: error.message } + } + + const result: Collaborator[] = (collaborators || []).map((c) => { + const profile = c.profiles as unknown as { display_name: string | null; email: string | null } | null + return { + id: c.id, + user_id: c.user_id, + role: c.role as 'owner' | 'editor' | 'viewer', + invited_at: c.invited_at, + accepted_at: c.accepted_at, + display_name: profile?.display_name || null, + email: profile?.email || null, + } + }) + + return { success: true, data: result } +} + +export async function inviteCollaborator( + projectId: string, + email: string, + role: 'editor' | 'viewer' +): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { success: false, error: 'Not authenticated' } + } + + // Verify user owns the project + const { data: project } = await supabase + .from('projects') + .select('id, user_id') + .eq('id', projectId) + .eq('user_id', user.id) + .single() + + if (!project) { + return { success: false, error: 'Only the project owner can invite collaborators' } + } + + // Find the user by email in profiles + const { data: targetProfile } = await supabase + .from('profiles') + .select('id, email') + .eq('email', email) + .single() + + if (!targetProfile) { + return { success: false, error: 'No user found with that email address' } + } + + // Cannot invite yourself + if (targetProfile.id === user.id) { + return { success: false, error: 'You cannot invite yourself' } + } + + // Check if already a collaborator + const { data: existing } = await supabase + .from('project_collaborators') + .select('id') + .eq('project_id', projectId) + .eq('user_id', targetProfile.id) + .single() + + if (existing) { + return { success: false, error: 'This user is already a collaborator' } + } + + // Insert collaborator + const { error: insertError } = await supabase + .from('project_collaborators') + .insert({ + project_id: projectId, + user_id: targetProfile.id, + role, + invited_at: new Date().toISOString(), + accepted_at: new Date().toISOString(), // Auto-accept for now + }) + + if (insertError) { + return { success: false, error: insertError.message } + } + + return { success: true } +} + +export async function updateCollaboratorRole( + projectId: string, + collaboratorId: string, + role: 'editor' | 'viewer' +): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { success: false, error: 'Not authenticated' } + } + + // Verify user owns the project + const { data: project } = await supabase + .from('projects') + .select('id, user_id') + .eq('id', projectId) + .eq('user_id', user.id) + .single() + + if (!project) { + return { success: false, error: 'Only the project owner can change roles' } + } + + const { error } = await supabase + .from('project_collaborators') + .update({ role }) + .eq('id', collaboratorId) + .eq('project_id', projectId) + + if (error) { + return { success: false, error: error.message } + } + + return { success: true } +} + +export async function removeCollaborator( + projectId: string, + collaboratorId: string +): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { success: false, error: 'Not authenticated' } + } + + // Verify user owns the project + const { data: project } = await supabase + .from('projects') + .select('id, user_id') + .eq('id', projectId) + .eq('user_id', user.id) + .single() + + if (!project) { + return { success: false, error: 'Only the project owner can remove collaborators' } + } + + const { error } = await supabase + .from('project_collaborators') + .delete() + .eq('id', collaboratorId) + .eq('project_id', projectId) + + if (error) { + return { success: false, error: error.message } + } + + return { success: true } +} diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index ca5abda..c5511e8 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -19,15 +19,52 @@ export default async function EditorPage({ params }: PageProps) { return null } - const { data: project, error } = await supabase + // Fetch user's display name for presence + const { data: profile } = await supabase + .from('profiles') + .select('display_name') + .eq('id', user.id) + .single() + + const userDisplayName = profile?.display_name || user.email || 'Anonymous' + + // Try to load as owner first + const { data: ownedProject } = await supabase .from('projects') .select('id, name, flowchart_data') .eq('id', projectId) .eq('user_id', user.id) .single() - if (error || !project) { - notFound() + let project = ownedProject + let isOwner = true + + // If not the owner, check if the user is a collaborator + if (!project) { + // RLS on projects allows collaborators to SELECT shared projects + const { data: collab } = await supabase + .from('project_collaborators') + .select('id, role') + .eq('project_id', projectId) + .eq('user_id', user.id) + .single() + + if (!collab) { + notFound() + } + + const { data: sharedProject } = await supabase + .from('projects') + .select('id, name, flowchart_data') + .eq('id', projectId) + .single() + + if (!sharedProject) { + notFound() + } + + project = sharedProject + isOwner = false } const rawData = project.flowchart_data || {} @@ -42,7 +79,7 @@ export default async function EditorPage({ params }: PageProps) { // the project was created before these features existed and may need auto-migration const needsMigration = !rawData.characters && !rawData.variables - return ( + return (<>
@@ -99,10 +136,14 @@ export default async function EditorPage({ params }: PageProps) {
+ ) } diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..f4d613f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -24,3 +24,16 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Export validation warning highlighting for React Flow nodes */ +.react-flow__node.export-warning-node { + outline: 3px solid #f97316; + outline-offset: 2px; + border-radius: 6px; + animation: pulse-warning 1.5s ease-in-out infinite; +} + +@keyframes pulse-warning { + 0%, 100% { outline-color: #f97316; } + 50% { outline-color: #fb923c; } +} diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index cd47ec0..707aad2 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -8,8 +8,10 @@ interface ProjectCardProps { id: string name: string updatedAt: string - onDelete: (id: string) => void - onRename: (id: string, newName: string) => void + onDelete?: (id: string) => void + onRename?: (id: string, newName: string) => void + shared?: boolean + sharedRole?: string } function formatDate(dateString: string): string { @@ -29,6 +31,8 @@ export default function ProjectCard({ updatedAt, onDelete, onRename, + shared, + sharedRole, }: ProjectCardProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) @@ -62,7 +66,7 @@ export default function ProjectCard({ setIsDeleting(false) setShowDeleteDialog(false) - onDelete(id) + onDelete?.(id) } const handleCancelDelete = () => { @@ -106,7 +110,7 @@ export default function ProjectCard({ setIsRenaming(false) setShowRenameDialog(false) - onRename(id, newName.trim()) + onRename?.(id, newName.trim()) } const handleCancelRename = () => { @@ -122,46 +126,55 @@ export default function ProjectCard({ onClick={handleCardClick} className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600" > -
- - + -
+ + + + +
+ )} + {shared && ( +
+ + {sharedRole === 'editor' ? 'Editor' : 'Viewer'} + +
+ )}

{name}

diff --git a/src/components/editor/ExportValidationModal.tsx b/src/components/editor/ExportValidationModal.tsx new file mode 100644 index 0000000..cdad515 --- /dev/null +++ b/src/components/editor/ExportValidationModal.tsx @@ -0,0 +1,131 @@ +'use client' + +export type ValidationIssue = { + nodeId: string + nodeType: 'dialogue' | 'choice' | 'variable' | 'edge' + contentSnippet: string + undefinedReference: string + referenceType: 'character' | 'variable' +} + +type ExportValidationModalProps = { + issues: ValidationIssue[] + onExportAnyway: () => void + onCancel: () => void +} + +export default function ExportValidationModal({ + issues, + onExportAnyway, + onCancel, +}: ExportValidationModalProps) { + const characterIssues = issues.filter((i) => i.referenceType === 'character') + const variableIssues = issues.filter((i) => i.referenceType === 'variable') + + return ( +
+
+
+
+ + + +

+ Export Validation Issues +

+
+

+ {issues.length} undefined reference{issues.length !== 1 ? 's' : ''} found. These nodes/edges reference characters or variables that no longer exist. +

+
+ +
+ {characterIssues.length > 0 && ( +
+

+ Undefined Characters +

+
    + {characterIssues.map((issue, idx) => ( +
  • +
    +
    + + {issue.nodeType} + +

    + {issue.contentSnippet} +

    +
    + + ID: {issue.undefinedReference.slice(0, 8)}... + +
    +
  • + ))} +
+
+ )} + + {variableIssues.length > 0 && ( +
+

+ Undefined Variables +

+
    + {variableIssues.map((issue, idx) => ( +
  • +
    +
    + + {issue.nodeType} + +

    + {issue.contentSnippet} +

    +
    + + ID: {issue.undefinedReference.slice(0, 8)}... + +
    +
  • + ))} +
+
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/components/editor/PresenceAvatars.tsx b/src/components/editor/PresenceAvatars.tsx new file mode 100644 index 0000000..24e146c --- /dev/null +++ b/src/components/editor/PresenceAvatars.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { PresenceUser } from '@/lib/collaboration/realtime' + +type PresenceAvatarsProps = { + users: PresenceUser[] +} + +const MAX_VISIBLE = 5 + +// Generate a consistent color from a user ID hash +function getUserColor(userId: string): string { + let hash = 0 + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 + } + const colors = [ + '#EF4444', '#F97316', '#F59E0B', '#10B981', + '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', + '#6366F1', '#F43F5E', '#84CC16', '#06B6D4', + ] + return colors[Math.abs(hash) % colors.length] +} + +// Get initials from a display name (first letter of first two words) +function getInitials(displayName: string): string { + const parts = displayName.trim().split(/\s+/) + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase() + } + return displayName.slice(0, 2).toUpperCase() +} + +export default function PresenceAvatars({ users }: PresenceAvatarsProps) { + if (users.length === 0) return null + + const visibleUsers = users.slice(0, MAX_VISIBLE) + const overflow = users.length - MAX_VISIBLE + + return ( +
+ {visibleUsers.map((user) => { + const color = getUserColor(user.userId) + return ( +
+ {getInitials(user.displayName)} +
+ ) + })} + {overflow > 0 && ( +
1 ? 's' : ''}`} + > + +{overflow} +
+ )} +
+ ) +} diff --git a/src/components/editor/ShareModal.tsx b/src/components/editor/ShareModal.tsx new file mode 100644 index 0000000..88f7e18 --- /dev/null +++ b/src/components/editor/ShareModal.tsx @@ -0,0 +1,224 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + getCollaborators, + inviteCollaborator, + updateCollaboratorRole, + removeCollaborator, + type Collaborator, +} from '@/app/editor/[projectId]/actions' + +type ShareModalProps = { + projectId: string + isOwner: boolean + onClose: () => void +} + +export default function ShareModal({ projectId, isOwner, onClose }: ShareModalProps) { + const [collaborators, setCollaborators] = useState([]) + const [loading, setLoading] = useState(true) + const [email, setEmail] = useState('') + const [role, setRole] = useState<'editor' | 'viewer'>('editor') + const [inviting, setInviting] = useState(false) + const [error, setError] = useState(null) + const [successMessage, setSuccessMessage] = useState(null) + + const fetchCollaborators = useCallback(async () => { + const result = await getCollaborators(projectId) + if (result.success && result.data) { + setCollaborators(result.data) + } + setLoading(false) + }, [projectId]) + + useEffect(() => { + fetchCollaborators() + }, [fetchCollaborators]) + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault() + if (!email.trim()) return + + setInviting(true) + setError(null) + setSuccessMessage(null) + + const result = await inviteCollaborator(projectId, email.trim(), role) + if (result.success) { + setEmail('') + setSuccessMessage('Collaborator invited successfully') + fetchCollaborators() + } else { + setError(result.error || 'Failed to invite collaborator') + } + setInviting(false) + } + + const handleRoleChange = async (collaboratorId: string, newRole: 'editor' | 'viewer') => { + setError(null) + const result = await updateCollaboratorRole(projectId, collaboratorId, newRole) + if (result.success) { + setCollaborators((prev) => + prev.map((c) => (c.id === collaboratorId ? { ...c, role: newRole } : c)) + ) + } else { + setError(result.error || 'Failed to update role') + } + } + + const handleRemove = async (collaboratorId: string) => { + setError(null) + const result = await removeCollaborator(projectId, collaboratorId) + if (result.success) { + setCollaborators((prev) => prev.filter((c) => c.id !== collaboratorId)) + } else { + setError(result.error || 'Failed to remove collaborator') + } + } + + return ( +
+ + ) +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 6d51118..24fa6e7 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -1,5 +1,8 @@ 'use client' +import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime' +import PresenceAvatars from './PresenceAvatars' + type ToolbarProps = { onAddDialogue: () => void onAddChoice: () => void @@ -9,6 +12,23 @@ type ToolbarProps = { onExportRenpy: () => void onImport: () => void onProjectSettings: () => void + onShare: () => void + connectionState?: ConnectionState + presenceUsers?: PresenceUser[] +} + +const connectionLabel: Record = { + connecting: 'Connecting…', + connected: 'Connected', + disconnected: 'Disconnected', + reconnecting: 'Reconnecting…', +} + +const connectionColor: Record = { + connecting: 'bg-yellow-400', + connected: 'bg-green-400', + disconnected: 'bg-red-400', + reconnecting: 'bg-yellow-400', } export default function Toolbar({ @@ -20,6 +40,9 @@ export default function Toolbar({ onExportRenpy, onImport, onProjectSettings, + onShare, + connectionState, + presenceUsers, }: ToolbarProps) { return (
@@ -48,12 +71,31 @@ export default function Toolbar({
+ {presenceUsers && presenceUsers.length > 0 && ( +
+ +
+ )} + {connectionState && ( +
+ + + {connectionLabel[connectionState]} + +
+ )} +