incognitolm commited on
Commit
6919b2c
Β·
1 Parent(s): 31e0672
client/src/App.tsx CHANGED
@@ -9,6 +9,7 @@ import ResetPassword from './components/Auth/ResetPassword';
9
  import Dashboard from './components/Dashboard/Dashboard';
10
  import ProjectEditor from './components/ProjectEditor/ProjectEditor';
11
  import ErrorBoundary from './components/ErrorBoundary';
 
12
 
13
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
14
  const { token, initialized } = useAuthStore();
@@ -47,35 +48,37 @@ export default function App() {
47
  if (!ready) return null;
48
 
49
  return (
50
- <Routes>
51
- <Route path="/" element={<Navigate to="/dashboard" replace />} />
52
- <Route path="/login" element={<PublicRoute><LoginForm /></PublicRoute>} />
53
- <Route path="/register" element={<PublicRoute><RegisterForm /></PublicRoute>} />
54
- <Route path="/forgot-password" element={<PublicRoute><ForgotPassword /></PublicRoute>} />
55
- <Route path="/reset-password" element={<PublicRoute><ResetPassword /></PublicRoute>} />
56
- <Route
57
- path="/dashboard"
58
- element={
59
- <ProtectedRoute>
60
- <ErrorBoundary>
61
- <AppLayout>
62
- <Dashboard />
63
- </AppLayout>
64
- </ErrorBoundary>
65
- </ProtectedRoute>
66
- }
67
- />
68
- <Route
69
- path="/project/:id"
70
- element={
71
- <ProtectedRoute>
72
- <ErrorBoundary>
73
- <ProjectEditor />
74
- </ErrorBoundary>
75
- </ProtectedRoute>
76
- }
77
- />
78
- <Route path="*" element={<Navigate to="/dashboard" replace />} />
79
- </Routes>
 
 
80
  );
81
  }
 
9
  import Dashboard from './components/Dashboard/Dashboard';
10
  import ProjectEditor from './components/ProjectEditor/ProjectEditor';
11
  import ErrorBoundary from './components/ErrorBoundary';
12
+ import WebSocketProvider from './components/WebSocketProvider';
13
 
14
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
15
  const { token, initialized } = useAuthStore();
 
48
  if (!ready) return null;
49
 
50
  return (
51
+ <WebSocketProvider>
52
+ <Routes>
53
+ <Route path="/" element={<Navigate to="/dashboard" replace />} />
54
+ <Route path="/login" element={<PublicRoute><LoginForm /></PublicRoute>} />
55
+ <Route path="/register" element={<PublicRoute><RegisterForm /></PublicRoute>} />
56
+ <Route path="/forgot-password" element={<PublicRoute><ForgotPassword /></PublicRoute>} />
57
+ <Route path="/reset-password" element={<PublicRoute><ResetPassword /></PublicRoute>} />
58
+ <Route
59
+ path="/dashboard"
60
+ element={
61
+ <ProtectedRoute>
62
+ <ErrorBoundary>
63
+ <AppLayout>
64
+ <Dashboard />
65
+ </AppLayout>
66
+ </ErrorBoundary>
67
+ </ProtectedRoute>
68
+ }
69
+ />
70
+ <Route
71
+ path="/project/:id"
72
+ element={
73
+ <ProtectedRoute>
74
+ <ErrorBoundary>
75
+ <ProjectEditor />
76
+ </ErrorBoundary>
77
+ </ProtectedRoute>
78
+ }
79
+ />
80
+ <Route path="*" element={<Navigate to="/dashboard" replace />} />
81
+ </Routes>
82
+ </WebSocketProvider>
83
  );
84
  }
client/src/components/CodeView/CodeView.tsx CHANGED
@@ -2,7 +2,7 @@ import { useMemo, useCallback, useRef, useEffect, useState } from 'react';
2
  import { useEditorStore } from '../../store/editorStore';
3
  import { useProjectStore } from '../../store/projectStore';
4
  import { useCollaborationStore, Collaborator } from '../../store/collaborationStore';
5
- import { useWebSocket } from '../../hooks/useWebSocket';
6
  import { useParams } from 'react-router-dom';
7
  import { getBlocksForFramework } from '../../blocks/registry';
8
  import { compileWeb } from '../../compilers/web';
@@ -23,7 +23,7 @@ export default function CodeView() {
23
  const { currentProject } = useProjectStore();
24
  const { activeFileId, fileTree, viewMode, setViewMode, visualElements, activeFileContent, setActiveFileContent } = useEditorStore();
25
  const collaborators = useCollaborationStore((s) => s.collaborators);
26
- const { emitCursorMove, emitFileChanged } = useWebSocket(id);
27
  const editorRef = useRef<HTMLDivElement>(null);
28
  const [isEditing, setIsEditing] = useState(false);
29
 
 
2
  import { useEditorStore } from '../../store/editorStore';
3
  import { useProjectStore } from '../../store/projectStore';
4
  import { useCollaborationStore, Collaborator } from '../../store/collaborationStore';
5
+ import { useProjectSocket } from '../../hooks/useWebSocket';
6
  import { useParams } from 'react-router-dom';
7
  import { getBlocksForFramework } from '../../blocks/registry';
8
  import { compileWeb } from '../../compilers/web';
 
23
  const { currentProject } = useProjectStore();
24
  const { activeFileId, fileTree, viewMode, setViewMode, visualElements, activeFileContent, setActiveFileContent } = useEditorStore();
25
  const collaborators = useCollaborationStore((s) => s.collaborators);
26
+ const { emitCursorMove, emitFileChanged } = useProjectSocket(id);
27
  const editorRef = useRef<HTMLDivElement>(null);
28
  const [isEditing, setIsEditing] = useState(false);
29
 
client/src/components/ProjectEditor/ProjectEditor.tsx CHANGED
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
3
  import { useProjectStore } from '../../store/projectStore';
4
  import { useEditorStore } from '../../store/editorStore';
5
  import { useCollaborationStore } from '../../store/collaborationStore';
6
- import { useWebSocket, getSocket } from '../../hooks/useWebSocket';
7
  import {
8
  Blocks, Eye, Code2, FileType, Play,
9
  Save, ArrowLeft, Grid3X3, Maximize2, Minimize2,
@@ -66,7 +66,7 @@ export default function ProjectEditor() {
66
  const [showShareModal, setShowShareModal] = useState(false);
67
  const [showPreview, setShowPreview] = useState(false);
68
 
69
- const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useWebSocket(id);
70
 
71
  const connected = useCollaborationStore((s) => s.connected);
72
 
 
3
  import { useProjectStore } from '../../store/projectStore';
4
  import { useEditorStore } from '../../store/editorStore';
5
  import { useCollaborationStore } from '../../store/collaborationStore';
6
+ import { useProjectSocket, getSocket } from '../../hooks/useWebSocket';
7
  import {
8
  Blocks, Eye, Code2, FileType, Play,
9
  Save, ArrowLeft, Grid3X3, Maximize2, Minimize2,
 
66
  const [showShareModal, setShowShareModal] = useState(false);
67
  const [showPreview, setShowPreview] = useState(false);
68
 
69
+ const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useProjectSocket(id);
70
 
71
  const connected = useCollaborationStore((s) => s.connected);
72
 
client/src/components/WebSocketProvider.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { ReactNode } from 'react';
2
+ import { useWebSocket } from '../hooks/useWebSocket';
3
+
4
+ export default function WebSocketProvider({ children }: { children: ReactNode }) {
5
+ useWebSocket();
6
+ return <>{children}</>;
7
+ }
client/src/hooks/useWebSocket.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useCallback, useState } from 'react';
2
  import { io, Socket } from 'socket.io-client';
3
  import { useAuthStore } from '../store/authStore';
4
  import { useCollaborationStore, Collaborator } from '../store/collaborationStore';
@@ -23,42 +23,43 @@ export function useWsEvent(event: string, handler: (...args: any[]) => void) {
23
  }, [socket, event]);
24
  }
25
 
26
- export function useWebSocket(projectId: string | undefined) {
 
27
  const token = useAuthStore((s) => s.token);
28
- const {
29
- setCollaborators,
30
- addCollaborator,
31
- removeCollaborator,
32
- updateActiveFile,
33
- updateCursor,
34
- updateBlockSelection,
35
- updateElementHover,
36
- setConnected,
37
- } = useCollaborationStore();
38
- const { setConnected: setWsConnected, setSocket, pushPending, shiftPending, clearPending, socket } = useWsStore();
39
- const socketRef = useRef<Socket | null>(null);
40
- const [localSocket, setLocalSocket] = useState<Socket | null>(null);
41
 
42
  useEffect(() => {
43
- if (!token || !projectId) return;
44
-
45
- if (globalSocket?.connected) {
46
- globalSocket.disconnect();
 
 
 
 
 
47
  }
48
 
 
 
49
  const s = io({
50
  auth: { token },
51
  transports: ['websocket', 'polling'],
52
  });
53
 
54
- socketRef.current = s;
55
  globalSocket = s;
56
  setSocket(s);
57
 
58
  s.on('connect', () => {
59
  setConnected(true);
60
- setWsConnected(true);
61
- s.emit('join_project', { projectId });
 
 
 
 
 
62
 
63
  // Replay pending queue
64
  let pending = shiftPending();
@@ -70,107 +71,118 @@ export function useWebSocket(projectId: string | undefined) {
70
 
71
  s.on('disconnect', () => {
72
  setConnected(false);
73
- setWsConnected(false);
74
  });
75
 
76
- // ─── Collab event handlers ───
77
  s.on('collaborator_list', ({ collaborators }: { collaborators: Collaborator[] }) => {
78
- setCollaborators(collaborators);
79
  });
80
 
81
  s.on('collaborator_joined', (collaborator: Collaborator) => {
82
- addCollaborator(collaborator);
83
  });
84
 
85
  s.on('collaborator_left', ({ userId }: { userId: string }) => {
86
- removeCollaborator(userId);
87
  });
88
 
89
  s.on('collaborator_active_file', ({ userId, fileId }: { userId: string; fileId: string | null }) => {
90
- updateActiveFile(userId, fileId);
91
  });
92
 
93
  s.on('collaborator_cursor', ({ userId, fileId, cursor }: { userId: string; fileId: string; cursor: { line: number; ch: number } | null }) => {
94
- updateCursor(userId, fileId, cursor);
95
  });
96
 
97
  s.on('collaborator_block_selected', ({ userId, blockId }: { userId: string; blockId: string | null }) => {
98
- updateBlockSelection(userId, blockId);
99
  });
100
 
101
  s.on('collaborator_element_hover', ({ userId, elementId }: { userId: string; elementId: string | null }) => {
102
- updateElementHover(userId, elementId);
103
  });
104
 
105
- // ─── File handlers ───
106
  s.on('file_updated', ({ fileId, content }: { fileId: string; content: string }) => {
107
- const { updateFile } = useCollaborationStore.getState();
108
- updateFile(fileId, content);
109
  });
110
 
111
  s.on('file_added', ({ file, parentId }: { file: any; parentId?: string }) => {
112
- const { addFile } = useEditorStore.getState();
113
- addFile(file, parentId);
114
  });
115
 
116
  s.on('file_renamed', ({ fileId, name }: { fileId: string; name: string }) => {
117
- const { updateFile } = useEditorStore.getState();
118
- updateFile(fileId, { name });
119
  });
120
 
121
  s.on('file_deleted', ({ fileId }: { fileId: string }) => {
122
- const { removeFile } = useEditorStore.getState();
123
- removeFile(fileId);
124
  });
125
 
126
- // ─── Visual element handlers ───
127
  s.on('visual_element_added', ({ element, parentId }: { element: any; parentId?: string }) => {
128
- const { addVisualElement } = useEditorStore.getState();
129
- addVisualElement(element, parentId);
130
  });
131
 
132
  s.on('visual_element_updated', ({ elementId, updates }: { elementId: string; updates: any }) => {
133
- const { updateVisualElement } = useEditorStore.getState();
134
- updateVisualElement(elementId, updates);
135
  });
136
 
137
  s.on('visual_element_removed', ({ elementId }: { elementId: string }) => {
138
- const { removeVisualElement } = useEditorStore.getState();
139
- removeVisualElement(elementId);
140
  });
141
 
142
  s.on('visual_elements_reordered', ({ elements }: { elements: any[] }) => {
143
- const { setVisualElements } = useEditorStore.getState();
144
- setVisualElements(elements);
145
  });
146
 
147
- // ─── Save handler ───
148
  s.on('project_saved', ({ updatedAt, savedBy }: { updatedAt: number; savedBy: string }) => {
149
- const { setLastSaved, setSavedBy } = useCollaborationStore.getState();
150
- setLastSaved(updatedAt);
151
- setSavedBy(savedBy);
152
  });
153
 
154
- setLocalSocket(s);
155
-
156
  return () => {
157
- s.emit('leave_project', { projectId });
 
 
 
158
  s.disconnect();
159
  if (globalSocket === s) globalSocket = null;
160
  setSocket(null);
161
  setConnected(false);
162
- setWsConnected(false);
163
- setLocalSocket(null);
164
  };
165
- }, [token, projectId, setCollaborators, addCollaborator, removeCollaborator, updateActiveFile, updateCursor, updateBlockSelection, updateElementHover, setConnected, setWsConnected, setSocket, pushPending, shiftPending, clearPending]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  // ─── Generic emit for project actions ───
168
  const emitProjectAction = useCallback(<T>(action: string, data: any): Promise<T> => {
169
  return new Promise((resolve, reject) => {
170
- const s = socketRef.current || globalSocket;
171
  if (!s?.connected || !projectId) {
172
- // Queue for later
173
- pushPending(action, { projectId, ...data });
174
  reject(new Error('Not connected'));
175
  return;
176
  }
@@ -179,88 +191,87 @@ export function useWebSocket(projectId: string | undefined) {
179
  else resolve(response as T);
180
  });
181
  });
182
- }, [projectId, pushPending]);
183
 
184
- // ─── Specific emit functions ───
185
  const emitFileChanged = useCallback((fileId: string, content: string) => {
186
- const s = socketRef.current || globalSocket;
187
  if (s?.connected && projectId) {
188
  s.emit('file_changed', { projectId, fileId, content });
189
  }
190
  }, [projectId]);
191
 
192
  const emitActiveFileChanged = useCallback((fileId: string | null) => {
193
- const s = socketRef.current || globalSocket;
194
  if (s?.connected && projectId) {
195
  s.emit('active_file_changed', { projectId, fileId });
196
  }
197
  }, [projectId]);
198
 
199
  const emitCursorMove = useCallback((fileId: string, cursor: { line: number; ch: number }) => {
200
- const s = socketRef.current || globalSocket;
201
  if (s?.connected && projectId) {
202
  s.emit('cursor_move', { projectId, fileId, cursor });
203
  }
204
  }, [projectId]);
205
 
206
  const emitBlockSelection = useCallback((blockId: string | null) => {
207
- const s = socketRef.current || globalSocket;
208
  if (s?.connected && projectId) {
209
  s.emit('block_selection_changed', { projectId, blockId });
210
  }
211
  }, [projectId]);
212
 
213
  const emitElementHover = useCallback((elementId: string | null) => {
214
- const s = socketRef.current || globalSocket;
215
  if (s?.connected && projectId) {
216
  s.emit('visual_element_hover', { projectId, elementId });
217
  }
218
  }, [projectId]);
219
 
220
  const emitFileAdded = useCallback((file: any, parentId?: string) => {
221
- const s = socketRef.current || globalSocket;
222
  if (s?.connected && projectId) {
223
  s.emit('file_added', { projectId, file, parentId });
224
  }
225
  }, [projectId]);
226
 
227
  const emitFileRenamed = useCallback((fileId: string, name: string) => {
228
- const s = socketRef.current || globalSocket;
229
  if (s?.connected && projectId) {
230
  s.emit('file_renamed', { projectId, fileId, name });
231
  }
232
  }, [projectId]);
233
 
234
  const emitFileDeleted = useCallback((fileId: string) => {
235
- const s = socketRef.current || globalSocket;
236
  if (s?.connected && projectId) {
237
  s.emit('file_deleted', { projectId, fileId });
238
  }
239
  }, [projectId]);
240
 
241
  const emitVisualElementAdded = useCallback((element: any, parentId?: string) => {
242
- const s = socketRef.current || globalSocket;
243
  if (s?.connected && projectId) {
244
  s.emit('visual_element_added', { projectId, element, parentId });
245
  }
246
  }, [projectId]);
247
 
248
  const emitVisualElementUpdated = useCallback((elementId: string, updates: any) => {
249
- const s = socketRef.current || globalSocket;
250
  if (s?.connected && projectId) {
251
  s.emit('visual_element_updated', { projectId, elementId, updates });
252
  }
253
  }, [projectId]);
254
 
255
  const emitVisualElementRemoved = useCallback((elementId: string) => {
256
- const s = socketRef.current || globalSocket;
257
  if (s?.connected && projectId) {
258
  s.emit('visual_element_removed', { projectId, elementId });
259
  }
260
  }, [projectId]);
261
 
262
  const emitVisualElementsReordered = useCallback((elements: any[]) => {
263
- const s = socketRef.current || globalSocket;
264
  if (s?.connected && projectId) {
265
  s.emit('visual_elements_reordered', { projectId, elements });
266
  }
@@ -268,7 +279,7 @@ export function useWebSocket(projectId: string | undefined) {
268
 
269
  const emitSave = useCallback((data: any): Promise<any> => {
270
  return new Promise((resolve, reject) => {
271
- const s = socketRef.current || globalSocket;
272
  if (!s?.connected || !projectId) {
273
  reject(new Error('Not connected'));
274
  return;
@@ -280,114 +291,109 @@ export function useWebSocket(projectId: string | undefined) {
280
  });
281
  }, [projectId]);
282
 
283
- // ─── New: Pointer moves (throttled) ───
284
  const lastPointerEmit = useRef(0);
285
  const emitPointerMove = useCallback((x: number, y: number) => {
286
  const now = Date.now();
287
  if (now - lastPointerEmit.current < 50) return;
288
  lastPointerEmit.current = now;
289
- const s = socketRef.current || globalSocket;
290
  if (s?.connected && projectId) {
291
  s.emit('pointer_move', { projectId, x, y });
292
  }
293
  }, [projectId]);
294
 
295
- // ─── New: Block drag ───
296
  const emitBlockDragStart = useCallback((blockId: string, x: number, y: number) => {
297
- const s = socketRef.current || globalSocket;
298
  if (s?.connected && projectId) {
299
  s.emit('block_drag_start', { projectId, blockId, x, y });
300
  }
301
  }, [projectId]);
302
 
303
  const emitBlockDragMove = useCallback((blockId: string, x: number, y: number) => {
304
- const s = socketRef.current || globalSocket;
305
  if (s?.connected && projectId) {
306
  s.emit('block_drag_move', { projectId, blockId, x, y });
307
  }
308
  }, [projectId]);
309
 
310
  const emitBlockDragEnd = useCallback((blockId: string) => {
311
- const s = socketRef.current || globalSocket;
312
  if (s?.connected && projectId) {
313
  s.emit('block_drag_end', { projectId, blockId });
314
  }
315
  }, [projectId]);
316
 
317
- // ─── New: Text field cursors ───
318
  const emitTextFieldFocus = useCallback((fieldId: string, fileId: string, cursorPosition: number) => {
319
- const s = socketRef.current || globalSocket;
320
  if (s?.connected && projectId) {
321
  s.emit('text_field_focus', { projectId, fieldId, fileId, cursorPosition });
322
  }
323
  }, [projectId]);
324
 
325
  const emitTextFieldCursor = useCallback((fieldId: string, cursorPosition: number) => {
326
- const s = socketRef.current || globalSocket;
327
  if (s?.connected && projectId) {
328
  s.emit('text_field_cursor', { projectId, fieldId, cursorPosition });
329
  }
330
  }, [projectId]);
331
 
332
  const emitTextFieldBlur = useCallback((fieldId: string) => {
333
- const s = socketRef.current || globalSocket;
334
  if (s?.connected && projectId) {
335
  s.emit('text_field_blur', { projectId, fieldId });
336
  }
337
  }, [projectId]);
338
 
339
- // ─── New: CSS rule/property emits ───
340
  const emitCssRuleAdded = useCallback((rule: any) => {
341
- const s = socketRef.current || globalSocket;
342
  if (s?.connected && projectId) {
343
  s.emit('css_rule_added', { projectId, rule });
344
  }
345
  }, [projectId]);
346
 
347
  const emitCssRuleUpdated = useCallback((ruleId: string, updates: any) => {
348
- const s = socketRef.current || globalSocket;
349
  if (s?.connected && projectId) {
350
  s.emit('css_rule_updated', { projectId, ruleId, updates });
351
  }
352
  }, [projectId]);
353
 
354
  const emitCssRuleRemoved = useCallback((ruleId: string) => {
355
- const s = socketRef.current || globalSocket;
356
  if (s?.connected && projectId) {
357
  s.emit('css_rule_removed', { projectId, ruleId });
358
  }
359
  }, [projectId]);
360
 
361
  const emitCssRuleReordered = useCallback((ruleIds: string[]) => {
362
- const s = socketRef.current || globalSocket;
363
  if (s?.connected && projectId) {
364
  s.emit('css_rule_reordered', { projectId, ruleIds });
365
  }
366
  }, [projectId]);
367
 
368
  const emitCssPropAdded = useCallback((ruleId: string, property: any) => {
369
- const s = socketRef.current || globalSocket;
370
  if (s?.connected && projectId) {
371
  s.emit('css_prop_added', { projectId, ruleId, property });
372
  }
373
  }, [projectId]);
374
 
375
  const emitCssPropUpdated = useCallback((ruleId: string, propertyId: string, updates: any) => {
376
- const s = socketRef.current || globalSocket;
377
  if (s?.connected && projectId) {
378
  s.emit('css_prop_updated', { projectId, ruleId, propertyId, updates });
379
  }
380
  }, [projectId]);
381
 
382
  const emitCssPropRemoved = useCallback((ruleId: string, propertyId: string) => {
383
- const s = socketRef.current || globalSocket;
384
  if (s?.connected && projectId) {
385
  s.emit('css_prop_removed', { projectId, ruleId, propertyId });
386
  }
387
  }, [projectId]);
388
 
389
  return {
390
- socket: localSocket,
391
  emitProjectAction,
392
  emitFileChanged,
393
  emitActiveFileChanged,
@@ -402,7 +408,6 @@ export function useWebSocket(projectId: string | undefined) {
402
  emitVisualElementRemoved,
403
  emitVisualElementsReordered,
404
  emitSave,
405
- // New emits
406
  emitPointerMove,
407
  emitBlockDragStart,
408
  emitBlockDragMove,
 
1
+ import { useEffect, useRef, useCallback } from 'react';
2
  import { io, Socket } from 'socket.io-client';
3
  import { useAuthStore } from '../store/authStore';
4
  import { useCollaborationStore, Collaborator } from '../store/collaborationStore';
 
23
  }, [socket, event]);
24
  }
25
 
26
+ // ─── Socket lifecycle: connect on token, reconnect with stored projectId ───
27
+ export function useWebSocket() {
28
  const token = useAuthStore((s) => s.token);
29
+ const { setConnected: setCollabConnected } = useCollaborationStore();
30
+ const { setConnected, setSocket, pushPending, shiftPending } = useWsStore();
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  useEffect(() => {
33
+ if (!token) {
34
+ if (globalSocket) {
35
+ globalSocket.disconnect();
36
+ globalSocket = null;
37
+ setSocket(null);
38
+ setConnected(false);
39
+ setCollabConnected(false);
40
+ }
41
+ return;
42
  }
43
 
44
+ if (globalSocket?.connected) return;
45
+
46
  const s = io({
47
  auth: { token },
48
  transports: ['websocket', 'polling'],
49
  });
50
 
 
51
  globalSocket = s;
52
  setSocket(s);
53
 
54
  s.on('connect', () => {
55
  setConnected(true);
56
+ setCollabConnected(true);
57
+
58
+ // Re-join project if we have one stored
59
+ const pid = useWsStore.getState().currentProjectId;
60
+ if (pid) {
61
+ s.emit('join_project', { projectId: pid });
62
+ }
63
 
64
  // Replay pending queue
65
  let pending = shiftPending();
 
71
 
72
  s.on('disconnect', () => {
73
  setConnected(false);
74
+ setCollabConnected(false);
75
  });
76
 
 
77
  s.on('collaborator_list', ({ collaborators }: { collaborators: Collaborator[] }) => {
78
+ useCollaborationStore.getState().setCollaborators(collaborators);
79
  });
80
 
81
  s.on('collaborator_joined', (collaborator: Collaborator) => {
82
+ useCollaborationStore.getState().addCollaborator(collaborator);
83
  });
84
 
85
  s.on('collaborator_left', ({ userId }: { userId: string }) => {
86
+ useCollaborationStore.getState().removeCollaborator(userId);
87
  });
88
 
89
  s.on('collaborator_active_file', ({ userId, fileId }: { userId: string; fileId: string | null }) => {
90
+ useCollaborationStore.getState().updateActiveFile(userId, fileId);
91
  });
92
 
93
  s.on('collaborator_cursor', ({ userId, fileId, cursor }: { userId: string; fileId: string; cursor: { line: number; ch: number } | null }) => {
94
+ useCollaborationStore.getState().updateCursor(userId, fileId, cursor);
95
  });
96
 
97
  s.on('collaborator_block_selected', ({ userId, blockId }: { userId: string; blockId: string | null }) => {
98
+ useCollaborationStore.getState().updateBlockSelection(userId, blockId);
99
  });
100
 
101
  s.on('collaborator_element_hover', ({ userId, elementId }: { userId: string; elementId: string | null }) => {
102
+ useCollaborationStore.getState().updateElementHover(userId, elementId);
103
  });
104
 
 
105
  s.on('file_updated', ({ fileId, content }: { fileId: string; content: string }) => {
106
+ useCollaborationStore.getState().updateFile(fileId, content);
 
107
  });
108
 
109
  s.on('file_added', ({ file, parentId }: { file: any; parentId?: string }) => {
110
+ useEditorStore.getState().addFile(file, parentId);
 
111
  });
112
 
113
  s.on('file_renamed', ({ fileId, name }: { fileId: string; name: string }) => {
114
+ useEditorStore.getState().updateFile(fileId, { name });
 
115
  });
116
 
117
  s.on('file_deleted', ({ fileId }: { fileId: string }) => {
118
+ useEditorStore.getState().removeFile(fileId);
 
119
  });
120
 
 
121
  s.on('visual_element_added', ({ element, parentId }: { element: any; parentId?: string }) => {
122
+ useEditorStore.getState().addVisualElement(element, parentId);
 
123
  });
124
 
125
  s.on('visual_element_updated', ({ elementId, updates }: { elementId: string; updates: any }) => {
126
+ useEditorStore.getState().updateVisualElement(elementId, updates);
 
127
  });
128
 
129
  s.on('visual_element_removed', ({ elementId }: { elementId: string }) => {
130
+ useEditorStore.getState().removeVisualElement(elementId);
 
131
  });
132
 
133
  s.on('visual_elements_reordered', ({ elements }: { elements: any[] }) => {
134
+ useEditorStore.getState().setVisualElements(elements);
 
135
  });
136
 
 
137
  s.on('project_saved', ({ updatedAt, savedBy }: { updatedAt: number; savedBy: string }) => {
138
+ useCollaborationStore.getState().setLastSaved(updatedAt);
139
+ useCollaborationStore.getState().setSavedBy(savedBy);
 
140
  });
141
 
 
 
142
  return () => {
143
+ const pid = useWsStore.getState().currentProjectId;
144
+ if (pid) {
145
+ s.emit('leave_project', { projectId: pid });
146
+ }
147
  s.disconnect();
148
  if (globalSocket === s) globalSocket = null;
149
  setSocket(null);
150
  setConnected(false);
151
+ setCollabConnected(false);
 
152
  };
153
+ }, [token, setCollabConnected, setConnected, setSocket, pushPending, shiftPending]);
154
+ }
155
+
156
+ // ─── Project-scoped socket: join/leave room + emit functions ───
157
+ export function useProjectSocket(projectId: string | undefined) {
158
+ const setCurrentProjectId = useWsStore((s) => s.setCurrentProjectId);
159
+
160
+ // Join/leave project room when projectId changes
161
+ useEffect(() => {
162
+ if (!projectId) {
163
+ setCurrentProjectId(null);
164
+ return;
165
+ }
166
+
167
+ setCurrentProjectId(projectId);
168
+ const s = globalSocket;
169
+ if (s?.connected) {
170
+ s.emit('join_project', { projectId });
171
+ }
172
+
173
+ return () => {
174
+ setCurrentProjectId(null);
175
+ if (s?.connected) {
176
+ s.emit('leave_project', { projectId });
177
+ }
178
+ };
179
+ }, [projectId, setCurrentProjectId]);
180
 
181
  // ─── Generic emit for project actions ───
182
  const emitProjectAction = useCallback(<T>(action: string, data: any): Promise<T> => {
183
  return new Promise((resolve, reject) => {
184
+ const s = globalSocket;
185
  if (!s?.connected || !projectId) {
 
 
186
  reject(new Error('Not connected'));
187
  return;
188
  }
 
191
  else resolve(response as T);
192
  });
193
  });
194
+ }, [projectId]);
195
 
 
196
  const emitFileChanged = useCallback((fileId: string, content: string) => {
197
+ const s = globalSocket;
198
  if (s?.connected && projectId) {
199
  s.emit('file_changed', { projectId, fileId, content });
200
  }
201
  }, [projectId]);
202
 
203
  const emitActiveFileChanged = useCallback((fileId: string | null) => {
204
+ const s = globalSocket;
205
  if (s?.connected && projectId) {
206
  s.emit('active_file_changed', { projectId, fileId });
207
  }
208
  }, [projectId]);
209
 
210
  const emitCursorMove = useCallback((fileId: string, cursor: { line: number; ch: number }) => {
211
+ const s = globalSocket;
212
  if (s?.connected && projectId) {
213
  s.emit('cursor_move', { projectId, fileId, cursor });
214
  }
215
  }, [projectId]);
216
 
217
  const emitBlockSelection = useCallback((blockId: string | null) => {
218
+ const s = globalSocket;
219
  if (s?.connected && projectId) {
220
  s.emit('block_selection_changed', { projectId, blockId });
221
  }
222
  }, [projectId]);
223
 
224
  const emitElementHover = useCallback((elementId: string | null) => {
225
+ const s = globalSocket;
226
  if (s?.connected && projectId) {
227
  s.emit('visual_element_hover', { projectId, elementId });
228
  }
229
  }, [projectId]);
230
 
231
  const emitFileAdded = useCallback((file: any, parentId?: string) => {
232
+ const s = globalSocket;
233
  if (s?.connected && projectId) {
234
  s.emit('file_added', { projectId, file, parentId });
235
  }
236
  }, [projectId]);
237
 
238
  const emitFileRenamed = useCallback((fileId: string, name: string) => {
239
+ const s = globalSocket;
240
  if (s?.connected && projectId) {
241
  s.emit('file_renamed', { projectId, fileId, name });
242
  }
243
  }, [projectId]);
244
 
245
  const emitFileDeleted = useCallback((fileId: string) => {
246
+ const s = globalSocket;
247
  if (s?.connected && projectId) {
248
  s.emit('file_deleted', { projectId, fileId });
249
  }
250
  }, [projectId]);
251
 
252
  const emitVisualElementAdded = useCallback((element: any, parentId?: string) => {
253
+ const s = globalSocket;
254
  if (s?.connected && projectId) {
255
  s.emit('visual_element_added', { projectId, element, parentId });
256
  }
257
  }, [projectId]);
258
 
259
  const emitVisualElementUpdated = useCallback((elementId: string, updates: any) => {
260
+ const s = globalSocket;
261
  if (s?.connected && projectId) {
262
  s.emit('visual_element_updated', { projectId, elementId, updates });
263
  }
264
  }, [projectId]);
265
 
266
  const emitVisualElementRemoved = useCallback((elementId: string) => {
267
+ const s = globalSocket;
268
  if (s?.connected && projectId) {
269
  s.emit('visual_element_removed', { projectId, elementId });
270
  }
271
  }, [projectId]);
272
 
273
  const emitVisualElementsReordered = useCallback((elements: any[]) => {
274
+ const s = globalSocket;
275
  if (s?.connected && projectId) {
276
  s.emit('visual_elements_reordered', { projectId, elements });
277
  }
 
279
 
280
  const emitSave = useCallback((data: any): Promise<any> => {
281
  return new Promise((resolve, reject) => {
282
+ const s = globalSocket;
283
  if (!s?.connected || !projectId) {
284
  reject(new Error('Not connected'));
285
  return;
 
291
  });
292
  }, [projectId]);
293
 
 
294
  const lastPointerEmit = useRef(0);
295
  const emitPointerMove = useCallback((x: number, y: number) => {
296
  const now = Date.now();
297
  if (now - lastPointerEmit.current < 50) return;
298
  lastPointerEmit.current = now;
299
+ const s = globalSocket;
300
  if (s?.connected && projectId) {
301
  s.emit('pointer_move', { projectId, x, y });
302
  }
303
  }, [projectId]);
304
 
 
305
  const emitBlockDragStart = useCallback((blockId: string, x: number, y: number) => {
306
+ const s = globalSocket;
307
  if (s?.connected && projectId) {
308
  s.emit('block_drag_start', { projectId, blockId, x, y });
309
  }
310
  }, [projectId]);
311
 
312
  const emitBlockDragMove = useCallback((blockId: string, x: number, y: number) => {
313
+ const s = globalSocket;
314
  if (s?.connected && projectId) {
315
  s.emit('block_drag_move', { projectId, blockId, x, y });
316
  }
317
  }, [projectId]);
318
 
319
  const emitBlockDragEnd = useCallback((blockId: string) => {
320
+ const s = globalSocket;
321
  if (s?.connected && projectId) {
322
  s.emit('block_drag_end', { projectId, blockId });
323
  }
324
  }, [projectId]);
325
 
 
326
  const emitTextFieldFocus = useCallback((fieldId: string, fileId: string, cursorPosition: number) => {
327
+ const s = globalSocket;
328
  if (s?.connected && projectId) {
329
  s.emit('text_field_focus', { projectId, fieldId, fileId, cursorPosition });
330
  }
331
  }, [projectId]);
332
 
333
  const emitTextFieldCursor = useCallback((fieldId: string, cursorPosition: number) => {
334
+ const s = globalSocket;
335
  if (s?.connected && projectId) {
336
  s.emit('text_field_cursor', { projectId, fieldId, cursorPosition });
337
  }
338
  }, [projectId]);
339
 
340
  const emitTextFieldBlur = useCallback((fieldId: string) => {
341
+ const s = globalSocket;
342
  if (s?.connected && projectId) {
343
  s.emit('text_field_blur', { projectId, fieldId });
344
  }
345
  }, [projectId]);
346
 
 
347
  const emitCssRuleAdded = useCallback((rule: any) => {
348
+ const s = globalSocket;
349
  if (s?.connected && projectId) {
350
  s.emit('css_rule_added', { projectId, rule });
351
  }
352
  }, [projectId]);
353
 
354
  const emitCssRuleUpdated = useCallback((ruleId: string, updates: any) => {
355
+ const s = globalSocket;
356
  if (s?.connected && projectId) {
357
  s.emit('css_rule_updated', { projectId, ruleId, updates });
358
  }
359
  }, [projectId]);
360
 
361
  const emitCssRuleRemoved = useCallback((ruleId: string) => {
362
+ const s = globalSocket;
363
  if (s?.connected && projectId) {
364
  s.emit('css_rule_removed', { projectId, ruleId });
365
  }
366
  }, [projectId]);
367
 
368
  const emitCssRuleReordered = useCallback((ruleIds: string[]) => {
369
+ const s = globalSocket;
370
  if (s?.connected && projectId) {
371
  s.emit('css_rule_reordered', { projectId, ruleIds });
372
  }
373
  }, [projectId]);
374
 
375
  const emitCssPropAdded = useCallback((ruleId: string, property: any) => {
376
+ const s = globalSocket;
377
  if (s?.connected && projectId) {
378
  s.emit('css_prop_added', { projectId, ruleId, property });
379
  }
380
  }, [projectId]);
381
 
382
  const emitCssPropUpdated = useCallback((ruleId: string, propertyId: string, updates: any) => {
383
+ const s = globalSocket;
384
  if (s?.connected && projectId) {
385
  s.emit('css_prop_updated', { projectId, ruleId, propertyId, updates });
386
  }
387
  }, [projectId]);
388
 
389
  const emitCssPropRemoved = useCallback((ruleId: string, propertyId: string) => {
390
+ const s = globalSocket;
391
  if (s?.connected && projectId) {
392
  s.emit('css_prop_removed', { projectId, ruleId, propertyId });
393
  }
394
  }, [projectId]);
395
 
396
  return {
 
397
  emitProjectAction,
398
  emitFileChanged,
399
  emitActiveFileChanged,
 
408
  emitVisualElementRemoved,
409
  emitVisualElementsReordered,
410
  emitSave,
 
411
  emitPointerMove,
412
  emitBlockDragStart,
413
  emitBlockDragMove,
client/src/store/projectStore.ts CHANGED
@@ -18,15 +18,21 @@ interface ProjectStore {
18
 
19
  function wsEmit<T>(event: string, data?: any): Promise<T> {
20
  return new Promise((resolve, reject) => {
21
- const socket = getSocket();
22
- if (!socket?.connected) {
23
- reject(new Error('Not connected to server'));
24
- return;
25
- }
26
- socket.emit(event, data ?? {}, (response: any) => {
27
- if (response?.error) reject(new Error(response.error));
28
- else resolve(response as T);
29
- });
 
 
 
 
 
 
30
  });
31
  }
32
 
 
18
 
19
  function wsEmit<T>(event: string, data?: any): Promise<T> {
20
  return new Promise((resolve, reject) => {
21
+ // Wait for socket to be connected (with timeout)
22
+ const attempt = (tries: number) => {
23
+ const socket = getSocket();
24
+ if (socket?.connected) {
25
+ socket.emit(event, data ?? {}, (response: any) => {
26
+ if (response?.error) reject(new Error(response.error));
27
+ else resolve(response as T);
28
+ });
29
+ } else if (tries < 50) {
30
+ setTimeout(() => attempt(tries + 1), 100);
31
+ } else {
32
+ reject(new Error('Not connected to server'));
33
+ }
34
+ };
35
+ attempt(0);
36
  });
37
  }
38
 
client/src/store/wsStore.ts CHANGED
@@ -7,6 +7,7 @@ interface WsStore {
7
  latency: number;
8
  socket: Socket | null;
9
  pendingQueue: { action: string; data: any }[];
 
10
 
11
  setConnected: (connected: boolean) => void;
12
  setSocket: (socket: Socket | null) => void;
@@ -17,6 +18,7 @@ interface WsStore {
17
  clearPending: () => void;
18
  shiftPending: () => { action: string; data: any } | undefined;
19
  getSocket: () => Socket | null;
 
20
  }
21
 
22
  export const useWsStore = create<WsStore>((set, get) => ({
@@ -25,6 +27,7 @@ export const useWsStore = create<WsStore>((set, get) => ({
25
  latency: 0,
26
  socket: null,
27
  pendingQueue: [],
 
28
 
29
  setConnected: (connected) => set({ connected }),
30
  setSocket: (socket) => set({ socket }),
@@ -53,4 +56,5 @@ export const useWsStore = create<WsStore>((set, get) => ({
53
  },
54
 
55
  getSocket: () => get().socket,
 
56
  }));
 
7
  latency: number;
8
  socket: Socket | null;
9
  pendingQueue: { action: string; data: any }[];
10
+ currentProjectId: string | null;
11
 
12
  setConnected: (connected: boolean) => void;
13
  setSocket: (socket: Socket | null) => void;
 
18
  clearPending: () => void;
19
  shiftPending: () => { action: string; data: any } | undefined;
20
  getSocket: () => Socket | null;
21
+ setCurrentProjectId: (id: string | null) => void;
22
  }
23
 
24
  export const useWsStore = create<WsStore>((set, get) => ({
 
27
  latency: 0,
28
  socket: null,
29
  pendingQueue: [],
30
+ currentProjectId: null,
31
 
32
  setConnected: (connected) => set({ connected }),
33
  setSocket: (socket) => set({ socket }),
 
56
  },
57
 
58
  getSocket: () => get().socket,
59
+ setCurrentProjectId: (id) => set({ currentProjectId: id }),
60
  }));
client/vite.config.ts CHANGED
@@ -17,6 +17,11 @@ export default defineConfig({
17
  target: 'http://localhost:3000',
18
  changeOrigin: true,
19
  },
 
 
 
 
 
20
  },
21
  },
22
  build: {
 
17
  target: 'http://localhost:3000',
18
  changeOrigin: true,
19
  },
20
+ '/socket.io': {
21
+ target: 'http://localhost:3000',
22
+ changeOrigin: true,
23
+ ws: true,
24
+ },
25
  },
26
  },
27
  build: {
server/src/services/realtime.ts CHANGED
@@ -652,6 +652,87 @@ export function initRealtime(httpServer: HttpServer): Server {
652
  }
653
  });
654
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  // ═══════════════════════════════════════════════
656
  // VERSION (stub β€” Phase G)
657
  // ═══════════════════════════════════════════════
 
652
  }
653
  });
654
 
655
+ // ═══════════════════════════════════════════════
656
+ // PROJECT UPDATE
657
+ // ═══════════════════════════════════════════════
658
+ socket.on('project_update', (data: { projectId: string; updates: any }, callback?: (response: any) => void) => {
659
+ try {
660
+ const db = getDatabase();
661
+ let project = db.prepare(
662
+ 'SELECT * FROM projects WHERE id = ? AND user_id = ?'
663
+ ).get(data.projectId, user.userId) as any;
664
+
665
+ let permission: string | null = 'admin';
666
+ if (!project) {
667
+ const collab = db.prepare(
668
+ "SELECT p.*, c.permission FROM projects p JOIN project_collaborators c ON p.id = c.project_id WHERE p.id = ? AND c.user_id = ?"
669
+ ).get(data.projectId, user.userId) as any;
670
+ if (!collab || collab.permission === 'view') {
671
+ if (callback) callback({ error: 'Permission denied' });
672
+ return;
673
+ }
674
+ project = collab;
675
+ permission = collab.permission;
676
+ }
677
+
678
+ const now = Date.now();
679
+ let encryptedData = project.encrypted_data;
680
+ let encryptedIv = project.encryption_iv;
681
+
682
+ if (data.updates.data) {
683
+ encryptedData = encryptCombined(JSON.stringify(data.updates.data));
684
+ encryptedIv = '';
685
+ }
686
+
687
+ db.prepare(`
688
+ UPDATE projects SET name = ?, description = ?, encrypted_data = ?, encryption_iv = ?, updated_at = ?
689
+ WHERE id = ?
690
+ `).run(
691
+ data.updates.name || project.name,
692
+ data.updates.description ?? project.description,
693
+ encryptedData, encryptedIv, now, data.projectId
694
+ );
695
+
696
+ if (callback) callback({ message: 'Project updated', updated_at: now });
697
+ } catch (error: any) {
698
+ console.error('Update project error:', error);
699
+ if (callback) callback({ error: 'Internal server error' });
700
+ }
701
+ });
702
+
703
+ // ═══════════════════════════════════════════════
704
+ // PROJECT SYNC (upload to HF storage)
705
+ // ═══════════════════════════════════════════════
706
+ socket.on('project_sync', async (data: { projectId: string }, callback?: (response: any) => void) => {
707
+ try {
708
+ const db = getDatabase();
709
+ const project = db.prepare(
710
+ 'SELECT * FROM projects WHERE id = ? AND user_id = ?'
711
+ ).get(data.projectId, user.userId) as any;
712
+
713
+ if (!project) {
714
+ if (callback) callback({ error: 'Project not found' });
715
+ return;
716
+ }
717
+
718
+ const { uploadToHFStorage } = await import('./hfStorage');
719
+ const result = await uploadToHFStorage(
720
+ `projects/${user.userId}/${data.projectId}.enc`,
721
+ Buffer.from(project.encrypted_data),
722
+ `Project: ${project.name}`
723
+ );
724
+
725
+ if (result.success) {
726
+ if (callback) callback({ message: 'Project synced to cloud storage' });
727
+ } else {
728
+ if (callback) callback({ error: 'Sync failed', details: result.message });
729
+ }
730
+ } catch (error: any) {
731
+ console.error('Sync project error:', error);
732
+ if (callback) callback({ error: 'Sync failed' });
733
+ }
734
+ });
735
+
736
  // ═══════════════════════════════════════════════
737
  // VERSION (stub β€” Phase G)
738
  // ═══════════════════════════════════════════════