From 577cceb883ddcfaafbd21a8cb7ea2b8afc4aca24 Mon Sep 17 00:00:00 2001 From: WaffleWaldo Date: Sat, 10 Jan 2026 21:24:58 -0500 Subject: [PATCH] feat: add collaborative sessions with Supabase real-time sync Add live sharing functionality that allows multiple users to annotate the same plan in real-time using Supabase Realtime subscriptions. - Add Supabase client setup and database schema - Create useCollaborativeSession hook for real-time sync - Add SyncStatusIndicator component for connection status - Add session creation/joining flow in ExportModal - Update types with SessionMode, SyncStatus, and CollaborativeSession Co-Authored-By: Claude Opus 4.5 --- .gitignore | 2 + CLAUDE.md | 45 ++- apps/hook/vite.config.ts | 2 + apps/portal/.gitignore | 1 + apps/portal/vite.config.ts | 8 + bun.lock | 27 +- packages/editor/App.tsx | 62 ++++ packages/ui/components/ExportModal.tsx | 136 +++++++- .../ui/components/SyncStatusIndicator.tsx | 108 +++++++ packages/ui/hooks/useCollaborativeSession.ts | 305 ++++++++++++++++++ packages/ui/lib/supabase.ts | 69 ++++ packages/ui/package.json | 2 + packages/ui/types.ts | 17 + packages/ui/utils/sessionSharing.ts | 207 ++++++++++++ supabase/schema.sql | 79 +++++ 15 files changed, 1060 insertions(+), 10 deletions(-) create mode 100644 apps/portal/.gitignore create mode 100644 packages/ui/components/SyncStatusIndicator.tsx create mode 100644 packages/ui/hooks/useCollaborativeSession.ts create mode 100644 packages/ui/lib/supabase.ts create mode 100644 packages/ui/utils/sessionSharing.ts create mode 100644 supabase/schema.sql diff --git a/.gitignore b/.gitignore index 95e670a73..f7f652426 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ opencode.json plannotator-local # Local research/reference docs (not for repo) reference/ + +.env diff --git a/CLAUDE.md b/CLAUDE.md index 4db1d799d..953fdaec5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,11 +24,14 @@ plannotator/ │ │ ├── integrations.ts # Obsidian, Bear integrations │ │ └── project.ts # Project name detection for tags │ ├── ui/ # Shared React components -│ │ ├── components/ # Viewer, Toolbar, Settings, etc. -│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts -│ │ ├── hooks/ # useSharing.ts +│ │ ├── components/ # Viewer, Toolbar, Settings, SyncStatusIndicator, etc. +│ │ ├── utils/ # parser.ts, sharing.ts, sessionSharing.ts, storage.ts, etc. +│ │ ├── hooks/ # useSharing.ts, useCollaborativeSession.ts +│ │ ├── lib/ # supabase.ts (client setup) │ │ └── types.ts │ └── editor/ # Main App.tsx +├── supabase/ +│ └── schema.sql # Database schema for collaborative sessions ├── .claude-plugin/marketplace.json # For marketplace install └── legacy/ # Old pre-monorepo code (reference only) ``` @@ -155,11 +158,11 @@ interface Block { Text highlighting uses `web-highlighter` library. Code blocks use manual `` wrapping (web-highlighter can't select inside `
`).
 
-## URL Sharing
+## URL Sharing (Quick Share)
 
 **Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts`
 
-Shares full plan + annotations via URL hash using deflate compression.
+Shares full plan + annotations via URL hash using deflate compression. This is a one-time snapshot - recipients get a frozen copy.
 
 **Payload format:**
 
@@ -191,6 +194,38 @@ type ShareableAnnotation =
 3. Apply `` highlights
 4. Clear hash from URL (prevents re-parse on refresh)
 
+## Collaborative Sessions (Live Share)
+
+**Location:** `packages/ui/utils/sessionSharing.ts`, `packages/ui/hooks/useCollaborativeSession.ts`
+
+Real-time collaboration via Supabase. Multiple users can annotate the same plan and see updates instantly.
+
+**Requires Supabase setup:**
+
+1. Create a Supabase project at https://supabase.com
+2. Run the schema in `supabase/schema.sql`
+3. Set environment variables:
+   ```
+   VITE_SUPABASE_URL=https://your-project.supabase.co
+   VITE_SUPABASE_ANON_KEY=your-anon-key
+   ```
+
+**Session URL format:** `share.plannotator.ai/session/{uuid}`
+
+**Flow:**
+
+1. User clicks "Create Live Session" in Export modal
+2. Session created in Supabase with plan markdown
+3. Shareable URL generated
+4. Recipients open URL → auto-subscribe to real-time updates
+5. Annotations sync bidirectionally via Supabase Realtime
+
+**Key files:**
+- `packages/ui/lib/supabase.ts` - Supabase client singleton
+- `packages/ui/hooks/useCollaborativeSession.ts` - Real-time sync hook
+- `packages/ui/utils/sessionSharing.ts` - Session CRUD operations
+- `packages/ui/components/SyncStatusIndicator.tsx` - Connection status display
+
 ## Settings Persistence
 
 **Location:** `packages/ui/utils/storage.ts`, `planSave.ts`, `agentSwitch.ts`
diff --git a/apps/hook/vite.config.ts b/apps/hook/vite.config.ts
index 33a8b0924..e3de587c4 100644
--- a/apps/hook/vite.config.ts
+++ b/apps/hook/vite.config.ts
@@ -6,6 +6,8 @@ import tailwindcss from '@tailwindcss/vite';
 import pkg from '../../package.json';
 
 export default defineConfig({
+  // Load .env from monorepo root
+  envDir: path.resolve(__dirname, '../..'),
   server: {
     port: 3000,
     host: '0.0.0.0',
diff --git a/apps/portal/.gitignore b/apps/portal/.gitignore
new file mode 100644
index 000000000..e985853ed
--- /dev/null
+++ b/apps/portal/.gitignore
@@ -0,0 +1 @@
+.vercel
diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts
index 822b099cf..a2cb94181 100644
--- a/apps/portal/vite.config.ts
+++ b/apps/portal/vite.config.ts
@@ -5,10 +5,18 @@ import tailwindcss from '@tailwindcss/vite';
 import pkg from '../../package.json';
 
 export default defineConfig({
+  // Load .env from monorepo root
+  envDir: path.resolve(__dirname, '../..'),
   server: {
     port: 3001,
     host: '0.0.0.0',
   },
+  // Enable SPA fallback for client-side routing (e.g., /session/:id)
+  appType: 'spa',
+  preview: {
+    port: 3001,
+    host: '0.0.0.0',
+  },
   define: {
     __APP_VERSION__: JSON.stringify(pkg.version),
   },
diff --git a/bun.lock b/bun.lock
index d5dd6337f..fe20976e3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -44,7 +44,7 @@
     },
     "apps/opencode-plugin": {
       "name": "@plannotator/opencode",
-      "version": "0.4.2",
+      "version": "0.4.15",
       "dependencies": {
         "@opencode-ai/plugin": "^1.0.218",
       },
@@ -82,7 +82,7 @@
     },
     "packages/server": {
       "name": "@plannotator/server",
-      "version": "0.4.2",
+      "version": "0.4.15",
       "peerDependencies": {
         "bun": ">=1.0.0",
       },
@@ -91,6 +91,7 @@
       "name": "@plannotator/ui",
       "version": "0.0.1",
       "dependencies": {
+        "@supabase/supabase-js": "^2.49.8",
         "highlight.js": "^11.11.1",
         "perfect-freehand": "^1.2.2",
         "react": "^19.2.3",
@@ -287,6 +288,18 @@
 
     "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
 
+    "@supabase/auth-js": ["@supabase/auth-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng=="],
+
+    "@supabase/functions-js": ["@supabase/functions-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw=="],
+
+    "@supabase/postgrest-js": ["@supabase/postgrest-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ=="],
+
+    "@supabase/realtime-js": ["@supabase/realtime-js@2.90.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w=="],
+
+    "@supabase/storage-js": ["@supabase/storage-js@2.90.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg=="],
+
+    "@supabase/supabase-js": ["@supabase/supabase-js@2.90.1", "", { "dependencies": { "@supabase/auth-js": "2.90.1", "@supabase/functions-js": "2.90.1", "@supabase/postgrest-js": "2.90.1", "@supabase/realtime-js": "2.90.1", "@supabase/storage-js": "2.90.1" } }, "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w=="],
+
     "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
 
     "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@@ -329,6 +342,10 @@
 
     "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
 
+    "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="],
+
+    "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
     "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
 
     "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
@@ -367,6 +384,8 @@
 
     "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
 
+    "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
+
     "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
 
     "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -443,6 +462,8 @@
 
     "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
 
+    "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
     "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
 
     "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -457,6 +478,8 @@
 
     "web-highlighter": ["web-highlighter@0.7.4", "", {}, "sha512-07mWw6ib+Abcr/fuKEWYPy1Y0MsCOz6yUUVw9xMpyt5UzyXWtSlcwzy2YqnccCP/LjtM9MZHQSo3dbGJII8h6w=="],
 
+    "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+
     "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
 
     "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx
index 9e496eea3..c7500035a 100644
--- a/packages/editor/App.tsx
+++ b/packages/editor/App.tsx
@@ -12,6 +12,8 @@ import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunnin
 import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup';
 import { Settings } from '@plannotator/ui/components/Settings';
 import { useSharing } from '@plannotator/ui/hooks/useSharing';
+import { useCollaborativeSession } from '@plannotator/ui/hooks/useCollaborativeSession';
+import { SyncStatusIndicator } from '@plannotator/ui/components/SyncStatusIndicator';
 import { storage } from '@plannotator/ui/utils/storage';
 import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
 import { getObsidianSettings } from '@plannotator/ui/utils/obsidian';
@@ -342,6 +344,21 @@ const App: React.FC = () => {
     }
   );
 
+  // Collaborative session for real-time sync
+  const {
+    sessionId,
+    isCollaborativeAvailable,
+    syncStatus,
+    syncError,
+    sessionUrl,
+    createCollaborativeSession,
+    syncAddAnnotation,
+    syncRemoveAnnotation,
+    pendingRemoteAnnotations,
+    pendingRemoteRemovals,
+    clearPendingRemote,
+  } = useCollaborativeSession(setMarkdown, annotations, setAnnotations);
+
   // Apply shared annotations to DOM after they're loaded
   useEffect(() => {
     if (pendingSharedAnnotations && pendingSharedAnnotations.length > 0) {
@@ -356,6 +373,27 @@ const App: React.FC = () => {
     }
   }, [pendingSharedAnnotations, clearPendingSharedAnnotations]);
 
+  // Apply remote annotations from collaborative session to DOM
+  useEffect(() => {
+    if (pendingRemoteAnnotations && pendingRemoteAnnotations.length > 0) {
+      const timer = setTimeout(() => {
+        viewerRef.current?.applySharedAnnotations(pendingRemoteAnnotations);
+        clearPendingRemote();
+      }, 100);
+      return () => clearTimeout(timer);
+    }
+  }, [pendingRemoteAnnotations, clearPendingRemote]);
+
+  // Handle remote annotation removals
+  useEffect(() => {
+    if (pendingRemoteRemovals && pendingRemoteRemovals.length > 0) {
+      pendingRemoteRemovals.forEach(id => {
+        viewerRef.current?.removeHighlight(id);
+      });
+      clearPendingRemote();
+    }
+  }, [pendingRemoteRemovals, clearPendingRemote]);
+
   const handleTaterModeChange = (enabled: boolean) => {
     setTaterMode(enabled);
     storage.setItem('plannotator-tater-mode', String(enabled));
@@ -522,12 +560,24 @@ const App: React.FC = () => {
     setAnnotations(prev => [...prev, ann]);
     setSelectedAnnotationId(ann.id);
     setIsPanelOpen(true);
+    // Sync to collaborative session if active
+    if (sessionId) {
+      syncAddAnnotation(ann);
+    }
   };
 
   const handleDeleteAnnotation = (id: string) => {
     viewerRef.current?.removeHighlight(id);
     setAnnotations(prev => prev.filter(a => a.id !== id));
     if (selectedAnnotationId === id) setSelectedAnnotationId(null);
+    // Sync to collaborative session if active
+    if (sessionId) {
+      syncRemoveAnnotation(id);
+    }
+  };
+
+  const handleCreateCollaborativeSession = async () => {
+    await createCollaborativeSession(markdown);
   };
 
   const handleIdentityChange = (oldIdentity: string, newIdentity: string) => {
@@ -585,6 +635,13 @@ const App: React.FC = () => {
                 {agentName}
               
             )}
+            {sessionId && (
+              
+            )}
           
 
           
@@ -725,6 +782,11 @@ const App: React.FC = () => { diffOutput={diffOutput} annotationCount={annotations.length} taterSprite={taterMode ? : undefined} + isCollaborativeAvailable={isCollaborativeAvailable} + sessionId={sessionId} + sessionUrl={sessionUrl} + syncStatus={syncStatus} + onCreateSession={handleCreateCollaborativeSession} /> {/* Feedback prompt dialog */} diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index ec9fc0a0f..2abe5e8ad 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -1,11 +1,13 @@ /** - * Export Modal with tabs for Share and Raw Diff + * Export Modal with tabs for Share, Collaborative, and Raw Diff * * Share tab (default): Shows shareable URL with copy button + * Collaborative tab: Create/join real-time sessions * Raw Diff tab: Shows human-readable diff output with copy/download */ import React, { useState } from 'react'; +import { SyncStatus } from '../types'; interface ExportModalProps { isOpen: boolean; @@ -15,9 +17,15 @@ interface ExportModalProps { diffOutput: string; annotationCount: number; taterSprite?: React.ReactNode; + // Collaborative session props + isCollaborativeAvailable?: boolean; + sessionId?: string | null; + sessionUrl?: string | null; + syncStatus?: SyncStatus; + onCreateSession?: () => Promise; } -type Tab = 'share' | 'diff'; +type Tab = 'share' | 'collab' | 'diff'; export const ExportModal: React.FC = ({ isOpen, @@ -27,9 +35,15 @@ export const ExportModal: React.FC = ({ diffOutput, annotationCount, taterSprite, + isCollaborativeAvailable = false, + sessionId, + sessionUrl, + syncStatus, + onCreateSession, }) => { const [activeTab, setActiveTab] = useState('share'); const [copied, setCopied] = useState(false); + const [isCreatingSession, setIsCreatingSession] = useState(false); if (!isOpen) return null; @@ -63,6 +77,27 @@ export const ExportModal: React.FC = ({ URL.revokeObjectURL(url); }; + const handleCreateSession = async () => { + if (!onCreateSession) return; + setIsCreatingSession(true); + try { + await onCreateSession(); + } finally { + setIsCreatingSession(false); + } + }; + + const handleCopySessionUrl = async () => { + if (!sessionUrl) return; + try { + await navigator.clipboard.writeText(sessionUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }; + return (
= ({ : 'text-muted-foreground hover:text-foreground' }`} > - Share + Quick Share + {isCollaborativeAvailable && ( + + )}
+ ) : activeTab === 'collab' ? ( +
+ {sessionId ? ( + // Already in a session + <> +
+ +
+ (e.target as HTMLInputElement).select()} + /> + +
+
+ +
+
+ + +
+ + Live session active - annotations sync in real-time + +
+ +

+ Share this URL with collaborators. Everyone with this link will see annotations update in real-time as they're added. +

+ + ) : ( + // No session yet + <> +
+
+ + + +
+

Start a Live Session

+

+ Create a collaborative session where multiple people can add annotations and see updates in real-time. +

+ +
+ +
+

+ Note: Quick Share (one-time URLs) will still work. Live sessions are for real-time collaboration. +

+
+ + )} +
) : (
               {diffOutput}
diff --git a/packages/ui/components/SyncStatusIndicator.tsx b/packages/ui/components/SyncStatusIndicator.tsx
new file mode 100644
index 000000000..1cbc0f0de
--- /dev/null
+++ b/packages/ui/components/SyncStatusIndicator.tsx
@@ -0,0 +1,108 @@
+/**
+ * Sync Status Indicator
+ *
+ * Shows the current sync status for collaborative sessions.
+ * Displays connection state with appropriate visual feedback.
+ */
+
+import React from 'react';
+import { SyncStatus } from '../types';
+
+interface SyncStatusIndicatorProps {
+  status: SyncStatus;
+  error?: string | null;
+  sessionUrl?: string | null;
+}
+
+export const SyncStatusIndicator: React.FC = ({
+  status,
+  error,
+  sessionUrl,
+}) => {
+  const getStatusConfig = () => {
+    switch (status) {
+      case 'connected':
+        return {
+          color: 'bg-green-500',
+          pulseColor: 'bg-green-400',
+          label: 'Synced',
+          animate: false,
+        };
+      case 'connecting':
+        return {
+          color: 'bg-yellow-500',
+          pulseColor: 'bg-yellow-400',
+          label: 'Connecting...',
+          animate: true,
+        };
+      case 'syncing':
+        return {
+          color: 'bg-blue-500',
+          pulseColor: 'bg-blue-400',
+          label: 'Syncing...',
+          animate: true,
+        };
+      case 'error':
+        return {
+          color: 'bg-red-500',
+          pulseColor: 'bg-red-400',
+          label: error || 'Error',
+          animate: false,
+        };
+      case 'disconnected':
+      default:
+        return {
+          color: 'bg-gray-400',
+          pulseColor: 'bg-gray-300',
+          label: 'Offline',
+          animate: false,
+        };
+    }
+  };
+
+  const config = getStatusConfig();
+
+  const handleCopyUrl = async () => {
+    if (sessionUrl) {
+      try {
+        await navigator.clipboard.writeText(sessionUrl);
+      } catch (e) {
+        console.error('Failed to copy URL:', e);
+      }
+    }
+  };
+
+  return (
+    
+ {/* Status dot */} +
+ {config.animate && ( + + )} + +
+ + {/* Status label */} + + {config.label} + + + {/* Copy session URL button (only when connected) */} + {status === 'connected' && sessionUrl && ( + + )} +
+ ); +}; diff --git a/packages/ui/hooks/useCollaborativeSession.ts b/packages/ui/hooks/useCollaborativeSession.ts new file mode 100644 index 000000000..96faf4a45 --- /dev/null +++ b/packages/ui/hooks/useCollaborativeSession.ts @@ -0,0 +1,305 @@ +/** + * Hook for real-time collaborative sessions + * + * Manages Supabase subscriptions and annotation sync for collaborative editing. + * Automatically syncs annotation changes to all connected clients. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Annotation, SyncStatus, CollaborativeSession } from '../types'; +import { getSupabase, DbAnnotation, RealtimeChannel } from '../lib/supabase'; +import { + fetchSession, + fetchSessionAnnotations, + addSessionAnnotation, + removeSessionAnnotation, + parseSessionFromUrl, + getSessionUrl, + createSession, + isCollaborativeAvailable, +} from '../utils/sessionSharing'; + +interface UseCollaborativeSessionResult { + /** Current session ID (null if not in a session) */ + sessionId: string | null; + + /** Whether collaborative features are available */ + isCollaborativeAvailable: boolean; + + /** Current sync status */ + syncStatus: SyncStatus; + + /** Error message if sync failed */ + syncError: string | null; + + /** Session URL for sharing */ + sessionUrl: string | null; + + /** Create a new collaborative session from the current plan */ + createCollaborativeSession: (planMarkdown: string) => Promise; + + /** Add an annotation and sync to server */ + syncAddAnnotation: (annotation: Annotation) => Promise; + + /** Remove an annotation and sync to server */ + syncRemoveAnnotation: (annotationId: string) => Promise; + + /** Annotations that were added remotely (need to be applied to DOM) */ + pendingRemoteAnnotations: Annotation[]; + + /** IDs of annotations that were removed remotely */ + pendingRemoteRemovals: string[]; + + /** Clear pending remote changes after applying */ + clearPendingRemote: () => void; +} + +export function useCollaborativeSession( + setMarkdown: (m: string) => void, + localAnnotations: Annotation[], + setAnnotations: (a: Annotation[]) => void +): UseCollaborativeSessionResult { + const [sessionId, setSessionId] = useState(null); + const [syncStatus, setSyncStatus] = useState('disconnected'); + const [syncError, setSyncError] = useState(null); + const [pendingRemoteAnnotations, setPendingRemoteAnnotations] = useState([]); + const [pendingRemoteRemovals, setPendingRemoteRemovals] = useState([]); + + const channelRef = useRef(null); + const localAnnotationIdsRef = useRef>(new Set()); + + // Track local annotation IDs to distinguish local vs remote changes + useEffect(() => { + localAnnotationIdsRef.current = new Set(localAnnotations.map(a => a.id)); + }, [localAnnotations]); + + // Check for session in URL on mount + useEffect(() => { + const urlSessionId = parseSessionFromUrl(); + if (urlSessionId) { + loadSession(urlSessionId); + } + }, []); + + // Load an existing session + const loadSession = useCallback(async (id: string) => { + setSyncStatus('connecting'); + setSyncError(null); + + try { + const session = await fetchSession(id); + if (!session) { + setSyncStatus('error'); + setSyncError('Session not found'); + return; + } + + // Set the plan content + setMarkdown(session.planMarkdown); + + // Fetch existing annotations + const annotations = await fetchSessionAnnotations(id); + if (annotations.length > 0) { + setAnnotations(annotations); + setPendingRemoteAnnotations(annotations); + } + + setSessionId(id); + subscribeToSession(id); + setSyncStatus('connected'); + } catch (e) { + console.error('Failed to load session:', e); + setSyncStatus('error'); + setSyncError('Failed to load session'); + } + }, [setMarkdown, setAnnotations]); + + // Subscribe to real-time updates for a session + const subscribeToSession = useCallback((id: string) => { + const supabase = getSupabase(); + if (!supabase) return; + + // Unsubscribe from any existing channel + if (channelRef.current) { + channelRef.current.unsubscribe(); + } + + const channel = supabase + .channel(`session:${id}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'annotations', + filter: `session_id=eq.${id}`, + }, + (payload) => { + handleRealtimeChange(payload); + } + ) + .subscribe((status) => { + if (status === 'SUBSCRIBED') { + setSyncStatus('connected'); + } else if (status === 'CHANNEL_ERROR') { + setSyncStatus('error'); + setSyncError('Real-time connection failed'); + } + }); + + channelRef.current = channel; + }, []); + + // Handle real-time database changes + const handleRealtimeChange = useCallback((payload: { eventType: string; new?: unknown; old?: unknown }) => { + const { eventType } = payload; + const newRecord = payload.new as DbAnnotation | undefined; + const oldRecord = payload.old as DbAnnotation | undefined; + + if (eventType === 'INSERT' && newRecord) { + // Skip if this is our own annotation + if (localAnnotationIdsRef.current.has(newRecord.id)) { + return; + } + + const annotation = dbAnnotationToAnnotation(newRecord); + setPendingRemoteAnnotations(prev => [...prev, annotation]); + setAnnotations(prev => { + // Avoid duplicates + if (prev.some(a => a.id === annotation.id)) { + return prev; + } + return [...prev, annotation]; + }); + } + + if (eventType === 'UPDATE' && newRecord?.deleted_at) { + // Soft delete - remove from local state + if (!localAnnotationIdsRef.current.has(newRecord.id)) { + setPendingRemoteRemovals(prev => [...prev, newRecord.id]); + setAnnotations(prev => prev.filter(a => a.id !== newRecord.id)); + } + } + + if (eventType === 'DELETE' && oldRecord) { + // Hard delete + if (!localAnnotationIdsRef.current.has(oldRecord.id)) { + setPendingRemoteRemovals(prev => [...prev, oldRecord.id]); + setAnnotations(prev => prev.filter(a => a.id !== oldRecord.id)); + } + } + }, [setAnnotations]); + + // Create a new collaborative session + const createCollaborativeSession = useCallback(async (planMarkdown: string): Promise => { + setSyncStatus('connecting'); + setSyncError(null); + + try { + const newSessionId = await createSession(planMarkdown); + if (!newSessionId) { + setSyncStatus('error'); + setSyncError('Failed to create session'); + return null; + } + + setSessionId(newSessionId); + subscribeToSession(newSessionId); + setSyncStatus('connected'); + + // Update URL to session URL (without reload) + const newUrl = `/session/${newSessionId}`; + window.history.pushState({}, '', newUrl); + + return newSessionId; + } catch (e) { + console.error('Failed to create session:', e); + setSyncStatus('error'); + setSyncError('Failed to create session'); + return null; + } + }, [subscribeToSession]); + + // Add annotation and sync to server + const syncAddAnnotation = useCallback(async (annotation: Annotation): Promise => { + if (!sessionId) return false; + + setSyncStatus('syncing'); + const success = await addSessionAnnotation(sessionId, annotation); + setSyncStatus(success ? 'connected' : 'error'); + + if (!success) { + setSyncError('Failed to sync annotation'); + } + + return success; + }, [sessionId]); + + // Remove annotation and sync to server + const syncRemoveAnnotation = useCallback(async (annotationId: string): Promise => { + if (!sessionId) return false; + + setSyncStatus('syncing'); + const success = await removeSessionAnnotation(annotationId); + setSyncStatus(success ? 'connected' : 'error'); + + if (!success) { + setSyncError('Failed to remove annotation'); + } + + return success; + }, [sessionId]); + + // Clear pending remote changes + const clearPendingRemote = useCallback(() => { + setPendingRemoteAnnotations([]); + setPendingRemoteRemovals([]); + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (channelRef.current) { + channelRef.current.unsubscribe(); + } + }; + }, []); + + return { + sessionId, + isCollaborativeAvailable: isCollaborativeAvailable(), + syncStatus, + syncError, + sessionUrl: sessionId ? getSessionUrl(sessionId) : null, + createCollaborativeSession, + syncAddAnnotation, + syncRemoveAnnotation, + pendingRemoteAnnotations, + pendingRemoteRemovals, + clearPendingRemote, + }; +} + +// Helper to convert DB annotation to app annotation +function dbAnnotationToAnnotation(db: DbAnnotation): Annotation { + const typeMap: Record = { + 'DELETION': 'DELETION', + 'REPLACEMENT': 'REPLACEMENT', + 'COMMENT': 'COMMENT', + 'INSERTION': 'INSERTION', + 'GLOBAL_COMMENT': 'GLOBAL_COMMENT', + }; + + return { + id: db.id, + blockId: '', + startOffset: 0, + endOffset: 0, + type: typeMap[db.type] as Annotation['type'], + text: db.text || undefined, + originalText: db.original_text, + createdA: new Date(db.created_at).getTime(), + author: db.author || undefined, + imagePaths: db.image_paths || undefined, + }; +} diff --git a/packages/ui/lib/supabase.ts b/packages/ui/lib/supabase.ts new file mode 100644 index 000000000..a3cd7e39f --- /dev/null +++ b/packages/ui/lib/supabase.ts @@ -0,0 +1,69 @@ +/** + * Supabase client for collaborative sessions + * + * Provides real-time sync for annotations across multiple users. + * Falls back gracefully when Supabase is not configured. + */ + +import { createClient, SupabaseClient, RealtimeChannel } from '@supabase/supabase-js'; + +// Get Supabase config from environment variables +// These are set at build time via Vite's import.meta.env +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string | undefined; +const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined; + +// Singleton client instance +let supabaseClient: SupabaseClient | null = null; + +/** + * Check if Supabase is configured + */ +export function isSupabaseConfigured(): boolean { + return !!(SUPABASE_URL && SUPABASE_ANON_KEY); +} + +/** + * Get the Supabase client singleton + * Returns null if Supabase is not configured + */ +export function getSupabase(): SupabaseClient | null { + if (!isSupabaseConfigured()) { + return null; + } + + if (!supabaseClient) { + supabaseClient = createClient(SUPABASE_URL!, SUPABASE_ANON_KEY!); + } + + return supabaseClient; +} + +// Database types for Supabase tables +export interface DbSession { + id: string; + plan_markdown: string; + created_at: string; + updated_at: string; +} + +export interface DbAnnotation { + id: string; + session_id: string; + type: string; + original_text: string; + text: string | null; + author: string | null; + position_context: string | null; + image_paths: string[] | null; + created_at: string; + deleted_at: string | null; +} + +// Realtime event types +export type AnnotationChangeEvent = { + eventType: 'INSERT' | 'UPDATE' | 'DELETE'; + new: DbAnnotation | null; + old: DbAnnotation | null; +}; + +export type { SupabaseClient, RealtimeChannel }; diff --git a/packages/ui/package.json b/packages/ui/package.json index c3816f934..10ee881f6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,9 +6,11 @@ "./components/*": "./components/*.tsx", "./utils/*": "./utils/*.ts", "./hooks/*": "./hooks/*.ts", + "./lib/*": "./lib/*.ts", "./types": "./types.ts" }, "dependencies": { + "@supabase/supabase-js": "^2.49.8", "highlight.js": "^11.11.1", "perfect-freehand": "^1.2.2", "react": "^19.2.3", diff --git a/packages/ui/types.ts b/packages/ui/types.ts index b6f0b0a57..b0fce1548 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -48,3 +48,20 @@ export interface DiffResult { modified: string; diffText: string; } + +// Collaborative session types +export type SessionMode = 'local' | 'collaborative'; + +export type SyncStatus = 'disconnected' | 'connecting' | 'connected' | 'syncing' | 'error'; + +export interface CollaborativeSession { + id: string; + planMarkdown: string; + createdAt: Date; + updatedAt: Date; +} + +export interface RemoteAnnotation extends Annotation { + isRemote: true; + syncedAt: number; +} diff --git a/packages/ui/utils/sessionSharing.ts b/packages/ui/utils/sessionSharing.ts new file mode 100644 index 000000000..ea09e8f28 --- /dev/null +++ b/packages/ui/utils/sessionSharing.ts @@ -0,0 +1,207 @@ +/** + * Session-based collaborative sharing utilities + * + * Enables real-time collaboration via Supabase sessions. + * Sessions are identified by UUID and contain a plan + synced annotations. + */ + +import { getSupabase, isSupabaseConfigured, DbSession, DbAnnotation } from '../lib/supabase'; +import { Annotation, AnnotationType, CollaborativeSession } from '../types'; + +/** + * Generate a shareable session URL + * Uses current host for local testing, falls back to production URL + */ +export function getSessionUrl(sessionId: string): string { + const baseUrl = typeof window !== 'undefined' + ? `${window.location.origin}/session` + : 'https://share.plannotator.ai/session'; + return `${baseUrl}/${sessionId}`; +} + +/** + * Parse a session ID from the current URL path + * Returns null if not on a session URL + */ +export function parseSessionFromUrl(): string | null { + const path = window.location.pathname; + const match = path.match(/^\/session\/([a-f0-9-]+)$/i); + return match ? match[1] : null; +} + +/** + * Create a new collaborative session + * Returns the session ID or null if creation failed + */ +export async function createSession(planMarkdown: string): Promise { + const supabase = getSupabase(); + if (!supabase) { + console.warn('Supabase not configured - cannot create session'); + return null; + } + + const { data, error } = await supabase + .from('sessions') + .insert({ plan_markdown: planMarkdown }) + .select('id') + .single(); + + if (error) { + console.error('Failed to create session:', error); + return null; + } + + return data.id; +} + +/** + * Fetch a session by ID + * Returns the session data or null if not found + */ +export async function fetchSession(sessionId: string): Promise { + const supabase = getSupabase(); + if (!supabase) { + return null; + } + + const { data, error } = await supabase + .from('sessions') + .select('*') + .eq('id', sessionId) + .single(); + + if (error || !data) { + console.error('Failed to fetch session:', error); + return null; + } + + const session = data as DbSession; + return { + id: session.id, + planMarkdown: session.plan_markdown, + createdAt: new Date(session.created_at), + updatedAt: new Date(session.updated_at), + }; +} + +/** + * Fetch all annotations for a session + */ +export async function fetchSessionAnnotations(sessionId: string): Promise { + const supabase = getSupabase(); + if (!supabase) { + return []; + } + + const { data, error } = await supabase + .from('annotations') + .select('*') + .eq('session_id', sessionId) + .is('deleted_at', null) + .order('created_at', { ascending: true }); + + if (error) { + console.error('Failed to fetch annotations:', error); + return []; + } + + return (data as DbAnnotation[]).map(dbAnnotationToAnnotation); +} + +/** + * Add an annotation to a session + */ +export async function addSessionAnnotation( + sessionId: string, + annotation: Annotation +): Promise { + const supabase = getSupabase(); + if (!supabase) { + return false; + } + + const dbAnnotation = annotationToDbAnnotation(sessionId, annotation); + + const { error } = await supabase + .from('annotations') + .insert(dbAnnotation); + + if (error) { + console.error('Failed to add annotation:', error); + return false; + } + + return true; +} + +/** + * Remove an annotation from a session (soft delete) + */ +export async function removeSessionAnnotation(annotationId: string): Promise { + const supabase = getSupabase(); + if (!supabase) { + return false; + } + + const { error } = await supabase + .from('annotations') + .update({ deleted_at: new Date().toISOString() }) + .eq('id', annotationId); + + if (error) { + console.error('Failed to remove annotation:', error); + return false; + } + + return true; +} + +// Convert database annotation to app annotation +function dbAnnotationToAnnotation(db: DbAnnotation): Annotation { + const typeMap: Record = { + 'DELETION': AnnotationType.DELETION, + 'REPLACEMENT': AnnotationType.REPLACEMENT, + 'COMMENT': AnnotationType.COMMENT, + 'INSERTION': AnnotationType.INSERTION, + 'GLOBAL_COMMENT': AnnotationType.GLOBAL_COMMENT, + }; + + return { + id: db.id, + blockId: '', // Will be populated during highlight restoration + startOffset: 0, + endOffset: 0, + type: typeMap[db.type] || AnnotationType.COMMENT, + text: db.text || undefined, + originalText: db.original_text, + createdA: new Date(db.created_at).getTime(), + author: db.author || undefined, + imagePaths: db.image_paths || undefined, + }; +} + +// Convert app annotation to database annotation +function annotationToDbAnnotation(sessionId: string, ann: Annotation): Omit { + // Build position context from surrounding text for text-finding on restore + const positionContext = ann.originalText.length > 0 + ? ann.originalText.substring(0, 100) // First 100 chars as context + : null; + + return { + id: ann.id, + session_id: sessionId, + type: ann.type, + original_text: ann.originalText, + text: ann.text || null, + author: ann.author || null, + position_context: positionContext, + image_paths: ann.imagePaths || null, + }; +} + +/** + * Check if collaborative sessions are available + */ +export function isCollaborativeAvailable(): boolean { + return isSupabaseConfigured(); +} diff --git a/supabase/schema.sql b/supabase/schema.sql new file mode 100644 index 000000000..8bd63fb04 --- /dev/null +++ b/supabase/schema.sql @@ -0,0 +1,79 @@ +-- Plannotator Collaborative Sessions Schema +-- Run this in your Supabase SQL Editor to set up the database + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Sessions table: stores the plan markdown for each collaborative session +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + plan_markdown TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Annotations table: stores annotations for each session +-- Note: id is TEXT (not UUID) because client generates IDs like "global-123" from web-highlighter +CREATE TABLE IF NOT EXISTS annotations ( + id TEXT PRIMARY KEY, + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('DELETION', 'INSERTION', 'REPLACEMENT', 'COMMENT', 'GLOBAL_COMMENT')), + original_text TEXT NOT NULL, + text TEXT, -- For comments/replacements/insertions + author TEXT, -- Tater identity + position_context TEXT, -- Text context for locating annotation + image_paths TEXT[], -- Array of image paths/URLs + created_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ -- Soft delete +); + +-- Index for faster session lookups +CREATE INDEX IF NOT EXISTS idx_annotations_session_id ON annotations(session_id); + +-- Index for filtering non-deleted annotations +CREATE INDEX IF NOT EXISTS idx_annotations_deleted_at ON annotations(deleted_at) WHERE deleted_at IS NULL; + +-- Trigger to auto-update updated_at on sessions +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +DROP TRIGGER IF EXISTS update_sessions_updated_at ON sessions; +CREATE TRIGGER update_sessions_updated_at + BEFORE UPDATE ON sessions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Enable Row Level Security (RLS) +ALTER TABLE sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE annotations ENABLE ROW LEVEL SECURITY; + +-- Public access policies (anyone can read/write - no auth required) +-- Note: For production, you may want to add rate limiting or auth + +-- Sessions: Allow all operations +CREATE POLICY "Sessions are publicly accessible" + ON sessions FOR ALL + USING (true) + WITH CHECK (true); + +-- Annotations: Allow all operations +CREATE POLICY "Annotations are publicly accessible" + ON annotations FOR ALL + USING (true) + WITH CHECK (true); + +-- Enable realtime for both tables +-- Run this in Supabase Dashboard > Database > Replication +-- Or via SQL: +ALTER PUBLICATION supabase_realtime ADD TABLE sessions; +ALTER PUBLICATION supabase_realtime ADD TABLE annotations; + +-- Grant permissions for anon key access +GRANT ALL ON sessions TO anon; +GRANT ALL ON annotations TO anon; +GRANT USAGE ON SCHEMA public TO anon;