From 048835246cda6a7dd0ac33ddd0fe4b26b351a3ab Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:15:05 +0700 Subject: [PATCH 1/3] feat: add real-time collaboration via Supabase Realtime Implement host/guest collaboration with room-based sessions: - ShareModal for creating and joining rooms with 6-char codes - Host broadcasts document state; guests receive via Broadcast channel - Presence-based peer tracking with role management (editor/viewer) - Remote cursor rendering in world-space with name labels - Read-only enforcement for viewer role across all components - Mid-interaction safety: queues remote updates during drag operations - Deep linking support via #/editor?room=CODE - HostLeftModal for session-ended handling - User guide updated with collaboration documentation --- .env.example | 2 + .gitignore | 2 + package-lock.json | 147 ++++++++++++ package.json | 1 + src/App.jsx | 10 +- src/Drawd.jsx | 108 ++++++++- src/collab/supabaseClient.js | 6 + src/components/CollabBadge.jsx | 98 ++++++++ src/components/CollabPresence.jsx | 158 ++++++++++++ src/components/HostLeftModal.jsx | 28 +++ src/components/RemoteCursors.jsx | 82 +++++++ src/components/ScreenNode.jsx | 6 +- src/components/ScreensPanel.jsx | 1 + src/components/ShareModal.jsx | 231 ++++++++++++++++++ src/components/ShortcutsPanel.jsx | 7 + src/components/Sidebar.jsx | 111 +++++---- src/components/ToolBar.jsx | 15 +- src/components/TopBar.jsx | 28 ++- src/constants.js | 6 + src/hooks/useCanvasMouseHandlers.js | 20 +- src/hooks/useCollaboration.js | 358 ++++++++++++++++++++++++++++ src/hooks/useKeyboardShortcuts.js | 6 + src/hooks/useScreenManager.js | 6 + src/pages/docs/userGuide.md | 52 ++++ src/styles/theme.js | 12 + src/utils/buildPayload.js | 14 ++ 26 files changed, 1447 insertions(+), 68 deletions(-) create mode 100644 .env.example create mode 100644 src/collab/supabaseClient.js create mode 100644 src/components/CollabBadge.jsx create mode 100644 src/components/CollabPresence.jsx create mode 100644 src/components/HostLeftModal.jsx create mode 100644 src/components/RemoteCursors.jsx create mode 100644 src/components/ShareModal.jsx create mode 100644 src/hooks/useCollaboration.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0839f12 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key-here diff --git a/.gitignore b/.gitignore index 8404604..0f13008 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules dist .DS_Store +.env +.env.local *.md !README.md !src/pages/docs/userGuide.md diff --git a/package-lock.json b/package-lock.json index c51639f..1cac420 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "drawd", "version": "1.0.0", "dependencies": { + "@supabase/supabase-js": "^2.99.2", "@vercel/analytics": "^2.0.1", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -1596,6 +1597,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.99.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.2.tgz", + "integrity": "sha512-uRGNXMKEw4VhwouNW7N0XDAGqJP9redHNDmWi17dTrcO1lvFfyRiXsqqfgnVC8aqtRn8kLkLPEzHjiRWsni+oQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.99.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.2.tgz", + "integrity": "sha512-xuXQARvjdfB1UPK1yUceZ5EGjOLkVz4rBAaloS9foXiAuseWEdgWBCxkIAFRxGBLGX8Uzo8kseq90jhPb+07Vg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.99.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.2.tgz", + "integrity": "sha512-ueiOVkbkTQ7RskwVmjR8zxWYw3VKOMxo1+qep+Dx/SgApqyhWBGd92waQb45tbLc7ydB5x8El8utXOLQTuTojQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.99.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.2.tgz", + "integrity": "sha512-J6Jm9601dkpZf3+EJ48ki2pM4sFtCNm/BI0l8iEnrczgg+JSEQkMoOW5VSpM54t0pNs69bsz5PTmYJahDZKiIQ==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.99.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.2.tgz", + "integrity": "sha512-V/FF8kX8JGSefsVCG1spCLSrHdNR/JFeUMn1jS9KG/Eizjx+evtdKQKLJXFgIylY/bKTXKhc2SYDPIGrIhzsug==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.99.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.2.tgz", + "integrity": "sha512-179rn5wq0wBAqqGwAwR7TUGg2NOaP+fkd5FCVbYJXby85fsRNPFoNJN8YRBepqX2tN7JJcnTjqaAMXuNjiyisA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.99.2", + "@supabase/functions-js": "2.99.2", + "@supabase/postgrest-js": "2.99.2", + "@supabase/realtime-js": "2.99.2", + "@supabase/storage-js": "2.99.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1730,6 +1811,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vercel/analytics": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", @@ -3586,6 +3691,15 @@ "node": ">= 14" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5407,6 +5521,12 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5527,6 +5647,12 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -5901,6 +6027,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 355cb0d..673342d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "vitest run" }, "dependencies": { + "@supabase/supabase-js": "^2.99.2", "@vercel/analytics": "^2.0.1", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/src/App.jsx b/src/App.jsx index d3dfa4b..da6d656 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,11 +8,17 @@ const Drawd = lazy(() => import("./Drawd")); function getRoute() { const hash = window.location.hash; - if (hash === "#/editor") return "editor"; + if (hash.startsWith("#/editor")) return "editor"; if (hash === "#/docs") return "docs"; return "landing"; } +function getRoomCodeFromHash() { + const hash = window.location.hash; + const match = hash.match(/[?&]room=([A-Za-z0-9]+)/); + return match ? match[1].toUpperCase() : null; +} + export default function App() { const [route, setRoute] = useState(getRoute); @@ -45,7 +51,7 @@ export default function App() { } > - + ); } else if (route === "docs") { diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 98c213a..61e2c8f 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { COLORS, FONTS, FONT_LINK, Z_INDEX } from "./styles/theme"; import { HEADER_HEIGHT, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT, FILE_EXTENSION, LEGACY_FILE_EXTENSION } from "./constants"; import { generateInstructionFiles } from "./utils/generateInstructionFiles"; @@ -12,6 +12,7 @@ import { useCanvasMouseHandlers } from "./hooks/useCanvasMouseHandlers"; import { useImportExport } from "./hooks/useImportExport"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useCanvasSelection } from "./hooks/useCanvasSelection"; +import { useCollaboration } from "./hooks/useCollaboration"; import { ScreenNode } from "./components/ScreenNode"; import { ConnectionLines } from "./components/ConnectionLines"; import { HotspotModal } from "./components/HotspotModal"; @@ -36,9 +37,14 @@ import { ScreenGroup } from "./components/ScreenGroup"; import { importFlow } from "./utils/importFlow"; import { detectDrawdFile } from "./utils/detectDrawdFile"; import { generateId } from "./utils/generateId"; +import { ShareModal } from "./components/ShareModal"; +import { CollabPresence } from "./components/CollabPresence"; +import { CollabBadge } from "./components/CollabBadge"; +import { RemoteCursors } from "./components/RemoteCursors"; +import { HostLeftModal } from "./components/HostLeftModal"; -export default function Drawd() { +export default function Drawd({ initialRoomCode }) { // ── Active tool ────────────────────────────────────────────────────────── const [activeTool, setActiveTool] = useState("select"); @@ -53,7 +59,7 @@ export default function Drawd() { fileInputRef, addScreen, addScreenAtCenter, removeScreen, removeScreens, renameScreen, moveScreen, moveScreens, handleImageUpload, onFileChange, handlePaste, handleCanvasDrop, saveHotspot, deleteHotspot, deleteHotspots, moveHotspot, moveHotspotToScreen, resizeHotspot, updateScreenDimensions, - updateScreenDescription, updateScreenNotes, updateScreenTbd, updateScreenRoles, updateScreenCodeRef, updateScreenCriteria, assignScreenImage, quickConnectHotspot, + updateScreenDescription, updateScreenNotes, updateScreenTbd, updateScreenRoles, updateScreenCodeRef, updateScreenCriteria, assignScreenImage, patchScreenImage, quickConnectHotspot, updateConnection, deleteConnection, addConnection, convertToConditionalGroup, addToConditionalGroup, saveConnectionGroup, deleteConnectionGroup, addState, updateStateName, addDocument, updateDocument, deleteDocument, @@ -143,6 +149,47 @@ export default function Drawd() { setDataModels((prev) => prev.filter((m) => m.id !== id)); }, []); + // ── Collaboration ────────────────────────────────────────────────────────── + const [showShareModal, setShowShareModal] = useState(!!initialRoomCode); + const pendingRemoteStateRef = useRef(null); + + const applyRemotePayload = useCallback((payload) => { + replaceAll( + payload.screens || [], + payload.connections || [], + (payload.screens || []).length + 1, + payload.documents || [], + ); + if (payload.featureBrief !== undefined) setFeatureBrief(payload.featureBrief); + if (payload.taskLink !== undefined) setTaskLink(payload.taskLink); + if (payload.techStack !== undefined) setTechStack(payload.techStack); + if (payload.dataModels !== undefined) setDataModels(payload.dataModels); + if (payload.stickyNotes !== undefined) setStickyNotes(payload.stickyNotes); + if (payload.screenGroups !== undefined) setScreenGroups(payload.screenGroups); + }, [replaceAll]); + + const applyPendingRemoteState = useCallback((payload) => { + applyRemotePayload(payload); + }, [applyRemotePayload]); + + const collab = useCollaboration({ + screens, connections, documents, + featureBrief, taskLink, techStack, + dataModels, stickyNotes, screenGroups, + applyRemoteState: (payload) => { + // If user is mid-drag, queue the update + if (dragging || hotspotInteraction?.mode === "draw" || hotspotInteraction?.mode === "reposition" || hotspotInteraction?.mode === "resize") { + pendingRemoteStateRef.current = payload; + return; + } + applyRemotePayload(payload); + }, + applyRemoteImage: patchScreenImage, + canvasRef, pan, zoom, + }); + + const isReadOnly = collab.isReadOnly; + // BFS forward from scopeRoot following connections const scopeScreenIds = scopeRoot ? (() => { const visited = new Set([scopeRoot]); @@ -346,6 +393,9 @@ export default function Drawd() { activeTool, setSelectedStickyNote, setSelectedScreenGroup, + isReadOnly, + pendingRemoteStateRef, + applyPendingRemoteState, }); // ── Import / export ──────────────────────────────────────────────────────────────── @@ -392,6 +442,7 @@ export default function Drawd() { selectedScreenGroup, setSelectedScreenGroup, deleteScreenGroup, undo, redo, saveNow, isFileSystemSupported, onSaveAs, onExport, onOpen, setActiveTool, + isReadOnly, }); // ── Paste handler ─────────────────────────────────────────────────────────────── @@ -557,6 +608,23 @@ export default function Drawd() { onNew={onNew} onOpen={onOpen} onSaveAs={onSaveAs} + collabState={collab} + onShare={() => setShowShareModal(true)} + collabBadge={collab.isConnected ? ( + + ) : null} + collabPresence={collab.isConnected ? ( + + ) : null} />
@@ -575,6 +643,7 @@ export default function Drawd() { onTaskLinkChange={setTaskLink} techStack={techStack} onTechStackChange={setTechStack} + isReadOnly={isReadOnly} /> {/* Canvas */} @@ -674,6 +743,7 @@ export default function Drawd() { isMultiSelected={canvasSelection.some((i) => i.type === "screen" && i.id === screen.id)} onToggleSelect={toggleSelection} onMultiDragStart={onMultiDragStart} + isReadOnly={isReadOnly} /> ))} {stickyNotes.map((note) => ( @@ -750,6 +820,7 @@ export default function Drawd() { onCancel={onConditionalPromptCancel} /> )} + {collab.isConnected && } {editingConditionGroup && ( addScreenAtCenter()} + isReadOnly={isReadOnly} onAddStickyNote={() => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); @@ -911,6 +983,7 @@ export default function Drawd() { onUpdateCodeRef={updateScreenCodeRef} onUpdateCriteria={updateScreenCriteria} onUpdateStatus={updateScreenStatus} + isReadOnly={isReadOnly} /> )} @@ -1023,6 +1096,35 @@ export default function Drawd() { )} {showShortcuts && setShowShortcuts(false)} />} + + {showShareModal && ( + { + collab.createRoom(name, color); + setShowShareModal(false); + }} + onJoinRoom={(code, name, color) => { + collab.joinRoom(code, name, color); + setShowShareModal(false); + }} + onClose={() => setShowShareModal(false)} + /> + )} + + {collab.hostLeft && ( + { + collab.dismissHostLeft(); + collab.leaveRoom(); + }} + onLeave={() => { + collab.dismissHostLeft(); + collab.leaveRoom(); + }} + /> + )}
); } diff --git a/src/collab/supabaseClient.js b/src/collab/supabaseClient.js new file mode 100644 index 0000000..9ae9b72 --- /dev/null +++ b/src/collab/supabaseClient.js @@ -0,0 +1,6 @@ +import { createClient } from "@supabase/supabase-js"; + +const url = import.meta.env.VITE_SUPABASE_URL; +const key = import.meta.env.VITE_SUPABASE_ANON_KEY; + +export const supabase = url && key ? createClient(url, key) : null; diff --git a/src/components/CollabBadge.jsx b/src/components/CollabBadge.jsx new file mode 100644 index 0000000..f0e8241 --- /dev/null +++ b/src/components/CollabBadge.jsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { COLORS, FONTS } from "../styles/theme"; +import { COPY_FEEDBACK_MS } from "../constants"; + +export function CollabBadge({ roomCode, isReadOnly, isConnected, onLeave }) { + const [copied, setCopied] = useState(false); + + const copyCode = () => { + navigator.clipboard.writeText(roomCode).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), COPY_FEEDBACK_MS); + }); + }; + + return ( +
+ {/* Room code pill */} + + + {/* Viewing badge */} + {isReadOnly && ( + + Viewing + + )} + + {/* Leave button */} + +
+ ); +} diff --git a/src/components/CollabPresence.jsx b/src/components/CollabPresence.jsx new file mode 100644 index 0000000..2a620e9 --- /dev/null +++ b/src/components/CollabPresence.jsx @@ -0,0 +1,158 @@ +import { useState, useRef, useEffect } from "react"; +import { COLORS, FONTS } from "../styles/theme"; + +function PeerCircle({ peer, isHost, onSetRole }) { + const [hovered, setHovered] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const onClick = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [menuOpen]); + + const initial = (peer.displayName || "?")[0].toUpperCase(); + const isViewer = peer.role === "viewer"; + const isPeerHost = peer.role === "host"; + + return ( +
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => { if (isHost && !isPeerHost) setMenuOpen((v) => !v); }} + style={{ + width: 28, + height: 28, + borderRadius: "50%", + background: peer.color, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: 12, + fontWeight: 700, + fontFamily: FONTS.mono, + color: "#fff", + cursor: isHost && !isPeerHost ? "pointer" : "default", + position: "relative", + border: "2px solid rgba(255,255,255,0.15)", + transition: "transform 0.15s", + transform: hovered ? "scale(1.1)" : "scale(1)", + }} + > + {initial} + {/* Host crown */} + {isPeerHost && ( + + ♚ + + )} + {/* Viewer eye */} + {isViewer && ( + + ◉ + + )} +
+ + {/* Tooltip */} + {hovered && !menuOpen && ( +
+ {peer.displayName} ({peer.role}) +
+ )} + + {/* Role dropdown (host only) */} + {menuOpen && isHost && !isPeerHost && ( +
+
+ {peer.displayName} +
+ {["editor", "viewer"].map((r) => ( + + ))} +
+ )} +
+ ); +} + +export function CollabPresence({ peers, isHost, onSetRole }) { + if (peers.length === 0) return null; + + return ( +
+ {peers.map((peer) => ( + + ))} +
+ ); +} diff --git a/src/components/HostLeftModal.jsx b/src/components/HostLeftModal.jsx new file mode 100644 index 0000000..61bedde --- /dev/null +++ b/src/components/HostLeftModal.jsx @@ -0,0 +1,28 @@ +import { styles, COLORS, FONTS } from "../styles/theme"; + +export function HostLeftModal({ onKeepState, onLeave }) { + return ( +
+
+

Session Ended

+

+ The host has left the collaboration session. You can keep the current state and continue working locally, or leave the session. +

+
+ + +
+
+
+ ); +} diff --git a/src/components/RemoteCursors.jsx b/src/components/RemoteCursors.jsx new file mode 100644 index 0000000..ecc63cf --- /dev/null +++ b/src/components/RemoteCursors.jsx @@ -0,0 +1,82 @@ +import { useState, useEffect, useRef } from "react"; +import { FONTS, Z_INDEX } from "../styles/theme"; +import { COLLAB_CURSOR_FADE_MS } from "../constants"; + +function CursorArrow({ color }) { + return ( + + + + ); +} + +function RemoteCursor({ cursor }) { + const [labelVisible, setLabelVisible] = useState(true); + const fadeTimerRef = useRef(null); + const prevPosRef = useRef({ x: cursor.x, y: cursor.y }); + + useEffect(() => { + // Detect movement + if (cursor.x !== prevPosRef.current.x || cursor.y !== prevPosRef.current.y) { + prevPosRef.current = { x: cursor.x, y: cursor.y }; + setLabelVisible(true); + if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current); + fadeTimerRef.current = setTimeout(() => setLabelVisible(false), COLLAB_CURSOR_FADE_MS); + } + return () => { + if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current); + }; + }, [cursor.x, cursor.y]); + + return ( +
+ +
+ {cursor.displayName} +
+
+ ); +} + +export function RemoteCursors({ cursors }) { + if (!cursors || cursors.length === 0) return null; + + return ( + <> + {cursors.map((c) => ( + + ))} + + ); +} diff --git a/src/components/ScreenNode.jsx b/src/components/ScreenNode.jsx index 96e6b91..6ac120c 100644 --- a/src/components/ScreenNode.jsx +++ b/src/components/ScreenNode.jsx @@ -11,6 +11,7 @@ export function ScreenNode({ onUpdateDescription, isSpaceHeld, onAddState, onDropImage, activeTool, scopeRoot, isInScope, onContextMenu, isMultiSelected, onToggleSelect, onMultiDragStart, + isReadOnly, }) { const [imgLoaded, setImgLoaded] = useState(false); const [isEditingDesc, setIsEditingDesc] = useState(false); @@ -76,6 +77,7 @@ export function ScreenNode({ if (e.target.closest(".connection-dot-right")) return; if (e.target.closest(".hotspot-drag-handle")) return; if (e.target.closest(".description-area")) return; + if (isReadOnly) { onSelect(screen.id); return; } if (e.shiftKey || e.metaKey) { e.stopPropagation(); onToggleSelect?.("screen", screen.id); @@ -274,7 +276,7 @@ export function ScreenNode({ {/* Action buttons */} -
+ {!isReadOnly &&
-
+
} {/* Image */} diff --git a/src/components/ScreensPanel.jsx b/src/components/ScreensPanel.jsx index 7298a0b..72040bd 100644 --- a/src/components/ScreensPanel.jsx +++ b/src/components/ScreensPanel.jsx @@ -17,6 +17,7 @@ export function ScreensPanel({ onTaskLinkChange, techStack, onTechStackChange, + isReadOnly, }) { const [briefOpen, setBriefOpen] = useState(false); const [techOpen, setTechOpen] = useState(false); diff --git a/src/components/ShareModal.jsx b/src/components/ShareModal.jsx new file mode 100644 index 0000000..47fd11a --- /dev/null +++ b/src/components/ShareModal.jsx @@ -0,0 +1,231 @@ +import { useState, useEffect } from "react"; +import { COLORS, FONTS, styles } from "../styles/theme"; +import { COPY_FEEDBACK_MS, DOMAIN } from "../constants"; + +export function ShareModal({ onClose, onCreateRoom, onJoinRoom, initialRoomCode, isCollabAvailable }) { + const [tab, setTab] = useState(initialRoomCode ? "join" : "create"); + const [displayName, setDisplayName] = useState(""); + const [color, setColor] = useState(COLORS.cursorPalette[0]); + const [joinCode, setJoinCode] = useState(initialRoomCode || ""); + const [createdCode, setCreatedCode] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const onKey = (e) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + const handleCreate = () => { + if (!displayName.trim()) return; + onCreateRoom(displayName.trim(), color); + }; + + const handleJoin = () => { + if (!displayName.trim() || joinCode.trim().length < 6) return; + onJoinRoom(joinCode.trim().toUpperCase(), displayName.trim(), color); + }; + + const copyCode = (code) => { + navigator.clipboard.writeText(code).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), COPY_FEEDBACK_MS); + }); + }; + + const shareLink = createdCode ? `https://${DOMAIN}/#/editor?room=${createdCode}` : ""; + + if (!isCollabAvailable) { + return ( +
+
e.stopPropagation()}> +

Share Session

+

+ Real-time collaboration requires Supabase configuration. Add VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY to your .env file. +

+ +
+
+ ); + } + + return ( +
+
e.stopPropagation()}> +

Share Session

+ + {/* Tab switcher */} +
+ {["create", "join"].map((t) => ( + + ))} +
+ + {/* Display name */} +
+ + setDisplayName(e.target.value)} + placeholder="Your name" + autoFocus + style={styles.input} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (tab === "create") handleCreate(); + else handleJoin(); + } + }} + /> +
+ + {/* Color picker */} +
+ +
+ {COLORS.cursorPalette.map((c) => ( +
+
+ + {tab === "create" && !createdCode && ( + + )} + + {tab === "create" && createdCode && ( +
+
+ Room Code +
+
copyCode(createdCode)} + style={{ + fontSize: 32, + fontWeight: 700, + fontFamily: FONTS.mono, + color: COLORS.accent, + letterSpacing: "0.2em", + cursor: "pointer", + padding: "12px 0", + background: COLORS.accent008, + borderRadius: 12, + border: `1px solid ${COLORS.accent025}`, + marginBottom: 12, + userSelect: "all", + }} + title="Click to copy" + > + {createdCode} +
+
+ {copied ? "Copied!" : "Click code to copy"} +
+
+ +
+
+ )} + + {tab === "join" && ( + <> +
+ + setJoinCode(e.target.value.toUpperCase().slice(0, 6))} + placeholder="XXXXXX" + maxLength={6} + style={{ ...styles.input, fontSize: 18, fontWeight: 700, letterSpacing: "0.15em", textAlign: "center" }} + onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }} + /> +
+ + + )} + + +
+
+ ); +} diff --git a/src/components/ShortcutsPanel.jsx b/src/components/ShortcutsPanel.jsx index 0603ba3..b61d7a4 100644 --- a/src/components/ShortcutsPanel.jsx +++ b/src/components/ShortcutsPanel.jsx @@ -36,6 +36,13 @@ const SHORTCUTS = [ { keys: ["\u2318/Ctrl", "O"], desc: "Open file" }, ], }, + { + category: "Collaboration", + items: [ + { keys: ["Share button"], desc: "Create or join a collaboration room" }, + { keys: ["Click room code"], desc: "Copy room code to clipboard" }, + ], + }, ]; const kbdStyle = { diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 08ce012..2f63b9e 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -2,7 +2,7 @@ import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE } from "../styles/theme"; import { useState } from "react"; import { SIDEBAR_WIDTH } from "../constants"; -export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles }) { +export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles, isReadOnly }) { const [draftNotes, setDraftNotes] = useState(screen.notes || ""); const [notesScreenId, setNotesScreenId] = useState(screen.id); const [draftCodeRef, setDraftCodeRef] = useState(screen.codeRef || ""); @@ -86,21 +86,23 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd {screen.name} - + {!isReadOnly && ( + + )} {/* TBD toggle */} @@ -306,12 +308,13 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd