incognitolm commited on
Commit
72aa761
·
1 Parent(s): 0dd493e
client/package.json CHANGED
@@ -22,6 +22,7 @@
22
  "clsx": "^2.1.0",
23
  "prismjs": "^1.29.0",
24
  "react-syntax-highlighter": "^15.5.0",
 
25
  "uuid": "^9.0.0",
26
  "file-saver": "^2.0.5",
27
  "jszip": "^3.10.1"
 
22
  "clsx": "^2.1.0",
23
  "prismjs": "^1.29.0",
24
  "react-syntax-highlighter": "^15.5.0",
25
+ "socket.io-client": "^4.7.5",
26
  "uuid": "^9.0.0",
27
  "file-saver": "^2.0.5",
28
  "jszip": "^3.10.1"
client/src/api/client.ts CHANGED
@@ -7,7 +7,7 @@ class ApiClient {
7
  this.token = token;
8
  }
9
 
10
- private async request<T>(
11
  method: string,
12
  path: string,
13
  body?: any
 
7
  this.token = token;
8
  }
9
 
10
+ async request<T>(
11
  method: string,
12
  path: string,
13
  body?: any
client/src/components/CodeView/CodeView.tsx CHANGED
@@ -1,6 +1,9 @@
1
- import { useMemo, useCallback } from 'react';
2
  import { useEditorStore } from '../../store/editorStore';
3
  import { useProjectStore } from '../../store/projectStore';
 
 
 
4
  import { getBlocksForFramework } from '../../blocks/registry';
5
  import { compileWeb } from '../../compilers/web';
6
  import { compileElectron } from '../../compilers/electron';
@@ -13,55 +16,86 @@ import {
13
  } from 'lucide-react';
14
  import { saveAs } from 'file-saver';
15
  import JSZip from 'jszip';
 
16
 
17
  export default function CodeView() {
 
18
  const { currentProject } = useProjectStore();
19
- const { activeFileId, fileTree, viewMode, setViewMode, visualElements } = useEditorStore();
 
 
 
 
20
 
21
  const compiledCode = useMemo(() => {
22
  if (!currentProject) return '';
 
23
 
24
  const activeFile = fileTree.find(f => f.id === activeFileId);
25
-
26
  const blocks = getBlocksForFramework(currentProject.framework);
27
-
28
  const compilerOptions = {
29
- blocks,
30
- fileTree,
31
- visualElements,
32
- activeFile,
33
  projectName: currentProject.name,
34
  };
35
 
36
  switch (currentProject.framework) {
37
- case 'web':
38
- return compileWeb(compilerOptions);
39
- case 'electron':
40
- return compileElectron(compilerOptions);
41
- case 'maui':
42
- return compileMaui(compilerOptions);
43
- case 'nodejs':
44
- return compileNodeJS(compilerOptions);
45
- default:
46
- return '// Select a framework to generate code';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
- }, [currentProject, activeFileId, fileTree, visualElements]);
 
 
 
 
 
 
 
 
 
 
49
 
50
  const handleDownload = useCallback(async () => {
51
  if (!currentProject) return;
52
  const zip = new JSZip();
53
-
54
  const addFilesToZip = (files: any[], currentPath: string = '') => {
55
  for (const file of files) {
56
  if (file.type === 'folder') {
57
- const folderPath = currentPath + file.name + '/';
58
- if (file.children) addFilesToZip(file.children, folderPath);
59
  } else {
60
  zip.file(currentPath + file.name, file.content || '');
61
  }
62
  }
63
  };
64
-
65
  addFilesToZip(fileTree);
66
  const blob = await zip.generateAsync({ type: 'blob' });
67
  saveAs(blob, `${currentProject.name}.zip`);
@@ -86,20 +120,24 @@ export default function CodeView() {
86
  <span className="badge-web">{currentProject?.framework || 'web'}</span>
87
  </div>
88
  <div className="flex items-center gap-2">
89
- <div className="flex bg-surface-800 rounded-lg p-0.5">
90
- <button
91
- onClick={() => setViewMode('design')}
92
- className={`px-2 py-1 rounded-md text-xs ${viewMode === 'design' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}
93
- >
94
- <Code2 className="w-3.5 h-3.5" />
95
- </button>
96
- <button
97
- onClick={() => setViewMode('split')}
98
- className={`px-2 py-1 rounded-md text-xs ${viewMode === 'split' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}
99
- >
100
- <SplitSquareHorizontal className="w-3.5 h-3.5" />
101
- </button>
102
- </div>
 
 
 
 
103
  <div className="w-px h-4 bg-surface-700" />
104
  <button onClick={handleCopy} className="btn-ghost p-1.5" title="Copy to clipboard">
105
  <Copy className="w-3.5 h-3.5" />
@@ -112,14 +150,74 @@ export default function CodeView() {
112
 
113
  {/* Code Content */}
114
  <div className="flex-1 overflow-auto">
115
- {compiledCode ? (
116
- <SyntaxHighlight code={compiledCode} language={getLanguage(currentProject?.framework || 'web', fileTree.find(f => f.id === activeFileId)?.type)} />
117
- ) : (
118
- <div className="flex items-center justify-center h-full text-surface-500 text-sm">
119
- <div className="text-center">
120
- <Code2 className="w-12 h-12 mx-auto mb-3 opacity-30" />
121
- <p>Add blocks to see generated code</p>
 
 
 
122
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </div>
124
  )}
125
  </div>
 
1
+ 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';
9
  import { compileElectron } from '../../compilers/electron';
 
16
  } from 'lucide-react';
17
  import { saveAs } from 'file-saver';
18
  import JSZip from 'jszip';
19
+ import { getColor } from '../Collaboration/CollaboratorAvatars';
20
 
21
  export default function CodeView() {
22
+ const { id } = useParams<{ id: string }>();
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
 
30
  const compiledCode = useMemo(() => {
31
  if (!currentProject) return '';
32
+ if (isEditing) return activeFileContent;
33
 
34
  const activeFile = fileTree.find(f => f.id === activeFileId);
 
35
  const blocks = getBlocksForFramework(currentProject.framework);
 
36
  const compilerOptions = {
37
+ blocks, fileTree, visualElements, activeFile,
 
 
 
38
  projectName: currentProject.name,
39
  };
40
 
41
  switch (currentProject.framework) {
42
+ case 'web': return compileWeb(compilerOptions);
43
+ case 'electron': return compileElectron(compilerOptions);
44
+ case 'maui': return compileMaui(compilerOptions);
45
+ case 'nodejs': return compileNodeJS(compilerOptions);
46
+ default: return '// Select a framework to generate code';
47
+ }
48
+ }, [currentProject, activeFileId, fileTree, visualElements, isEditing, activeFileContent]);
49
+
50
+ const collaboratorCursors = useMemo(() => {
51
+ if (!activeFileId) return [];
52
+ return collaborators.filter((c) => c.activeFileId === activeFileId && c.cursor && c.cursor.line != null);
53
+ }, [collaborators, activeFileId]);
54
+
55
+ const lines = useMemo(() => compiledCode.split('\n'), [compiledCode]);
56
+
57
+ const handleEditorClick = useCallback((e: React.MouseEvent) => {
58
+ const rect = editorRef.current?.getBoundingClientRect();
59
+ if (!rect) return;
60
+ const lineHeight = 20;
61
+ const line = Math.floor((e.clientY - rect.top + editorRef.current!.scrollTop) / lineHeight);
62
+ const ch = Math.min(0, 0);
63
+ if (activeFileId) {
64
+ emitCursorMove(activeFileId, { line: Math.max(0, line), ch });
65
+ }
66
+ }, [activeFileId, emitCursorMove]);
67
+
68
+ const cursorThrottle = useRef<number>();
69
+ const handleTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
70
+ const content = e.target.value;
71
+ setActiveFileContent(content);
72
+ if (activeFileId) {
73
+ emitFileChanged(activeFileId, content);
74
  }
75
+ // Track cursor position
76
+ const textarea = e.target;
77
+ const pos = textarea.selectionStart;
78
+ const before = content.substring(0, pos);
79
+ const line = before.split('\n').length - 1;
80
+ const ch = before.length - before.lastIndexOf('\n') - 1;
81
+ clearTimeout(cursorThrottle.current);
82
+ cursorThrottle.current = window.setTimeout(() => {
83
+ if (activeFileId) emitCursorMove(activeFileId, { line, ch });
84
+ }, 100);
85
+ }, [activeFileId, emitCursorMove, emitFileChanged, setActiveFileContent]);
86
 
87
  const handleDownload = useCallback(async () => {
88
  if (!currentProject) return;
89
  const zip = new JSZip();
 
90
  const addFilesToZip = (files: any[], currentPath: string = '') => {
91
  for (const file of files) {
92
  if (file.type === 'folder') {
93
+ if (file.children) addFilesToZip(file.children, currentPath + file.name + '/');
 
94
  } else {
95
  zip.file(currentPath + file.name, file.content || '');
96
  }
97
  }
98
  };
 
99
  addFilesToZip(fileTree);
100
  const blob = await zip.generateAsync({ type: 'blob' });
101
  saveAs(blob, `${currentProject.name}.zip`);
 
120
  <span className="badge-web">{currentProject?.framework || 'web'}</span>
121
  </div>
122
  <div className="flex items-center gap-2">
123
+ {!isEditing && (
124
+ <div className="flex bg-surface-800 rounded-lg p-0.5">
125
+ <button onClick={() => setViewMode('design')}
126
+ className={`px-2 py-1 rounded-md text-xs ${viewMode === 'design' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}>
127
+ <Code2 className="w-3.5 h-3.5" />
128
+ </button>
129
+ <button onClick={() => setViewMode('split')}
130
+ className={`px-2 py-1 rounded-md text-xs ${viewMode === 'split' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}>
131
+ <SplitSquareHorizontal className="w-3.5 h-3.5" />
132
+ </button>
133
+ </div>
134
+ )}
135
+ <button onClick={() => setIsEditing(!isEditing)}
136
+ className={`btn-ghost px-2 py-1 text-xs ${isEditing ? 'text-primary-400 bg-primary-500/10' : ''}`}
137
+ title={isEditing ? 'View compiled code' : 'Edit source directly'}>
138
+ <Code2 className="w-3.5 h-3.5" />
139
+ {isEditing ? 'Compiled' : 'Edit'}
140
+ </button>
141
  <div className="w-px h-4 bg-surface-700" />
142
  <button onClick={handleCopy} className="btn-ghost p-1.5" title="Copy to clipboard">
143
  <Copy className="w-3.5 h-3.5" />
 
150
 
151
  {/* Code Content */}
152
  <div className="flex-1 overflow-auto">
153
+ {isEditing ? (
154
+ <div className="relative h-full">
155
+ <textarea
156
+ value={activeFileContent}
157
+ onChange={handleTextChange}
158
+ className="w-full h-full bg-transparent text-transparent caret-white resize-none font-mono text-sm leading-5 p-4 absolute inset-0 z-10 outline-none"
159
+ spellCheck={false}
160
+ />
161
+ <div className="pointer-events-none p-4">
162
+ <SyntaxHighlight code={activeFileContent} language={getLanguage(currentProject?.framework || 'web', fileTree.find(f => f.id === activeFileId)?.type)} />
163
  </div>
164
+ {collaboratorCursors.length > 0 && (
165
+ <div className="absolute inset-0 pointer-events-none z-20">
166
+ {collaboratorCursors.map((c) => (
167
+ <div
168
+ key={c.userId}
169
+ className="absolute left-0 w-0.5 h-5"
170
+ style={{
171
+ top: `${c.cursor!.line * 20 + 16}px`,
172
+ backgroundColor: getColor(c.userId),
173
+ }}
174
+ >
175
+ <span
176
+ className="absolute left-0.5 -top-4 text-[9px] px-1 py-0.5 rounded-r whitespace-nowrap text-white font-bold"
177
+ style={{ backgroundColor: getColor(c.userId) }}
178
+ >
179
+ {c.username}
180
+ </span>
181
+ </div>
182
+ ))}
183
+ </div>
184
+ )}
185
+ </div>
186
+ ) : (
187
+ <div ref={editorRef} onClick={handleEditorClick} className="relative">
188
+ {compiledCode ? (
189
+ <>
190
+ <SyntaxHighlight code={compiledCode} language={getLanguage(currentProject?.framework || 'web', fileTree.find(f => f.id === activeFileId)?.type)} />
191
+ {collaboratorCursors.length > 0 && (
192
+ <div className="absolute inset-0 pointer-events-none top-0 left-0">
193
+ {collaboratorCursors.map((c) => (
194
+ <div
195
+ key={c.userId}
196
+ className="absolute left-0 w-0.5 h-5"
197
+ style={{
198
+ top: `${c.cursor!.line * 20 + 16}px`,
199
+ backgroundColor: getColor(c.userId),
200
+ }}
201
+ >
202
+ <span
203
+ className="absolute left-0.5 -top-4 text-[9px] px-1 py-0.5 rounded-r whitespace-nowrap text-white font-bold"
204
+ style={{ backgroundColor: getColor(c.userId) }}
205
+ >
206
+ {c.username}
207
+ </span>
208
+ </div>
209
+ ))}
210
+ </div>
211
+ )}
212
+ </>
213
+ ) : (
214
+ <div className="flex items-center justify-center h-full text-surface-500 text-sm">
215
+ <div className="text-center">
216
+ <Code2 className="w-12 h-12 mx-auto mb-3 opacity-30" />
217
+ <p>Add blocks to see generated code</p>
218
+ </div>
219
+ </div>
220
+ )}
221
  </div>
222
  )}
223
  </div>
client/src/components/Collaboration/CollaboratorAvatars.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCollaborationStore } from '../../store/collaborationStore';
2
+
3
+ const AVATAR_COLORS = [
4
+ '#22d3ee', '#f472b6', '#34d399', '#fbbf24', '#a78bfa',
5
+ '#fb923c', '#4ade80', '#f87171', '#38bdf8', '#c084fc',
6
+ ];
7
+
8
+ function getColor(userId: string): string {
9
+ let hash = 0;
10
+ for (let i = 0; i < userId.length; i++) {
11
+ hash = ((hash << 5) - hash) + userId.charCodeAt(i);
12
+ }
13
+ return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
14
+ }
15
+
16
+ export default function CollaboratorAvatars({ fileId }: { fileId?: string }) {
17
+ const collaborators = useCollaborationStore((s) => s.collaborators);
18
+ const connected = useCollaborationStore((s) => s.connected);
19
+
20
+ if (!connected || collaborators.length === 0) return null;
21
+
22
+ const visible = fileId
23
+ ? collaborators.filter((c) => c.activeFileId === fileId)
24
+ : collaborators;
25
+
26
+ if (visible.length === 0) return null;
27
+
28
+ return (
29
+ <div className="flex items-center -space-x-1.5">
30
+ {visible.map((c) => (
31
+ <div
32
+ key={c.userId}
33
+ className="relative group"
34
+ >
35
+ <div
36
+ className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2 ring-surface-900 cursor-help"
37
+ style={{ backgroundColor: getColor(c.userId) }}
38
+ >
39
+ {c.username.charAt(0).toUpperCase()}
40
+ </div>
41
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:block z-50">
42
+ <div className="bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-white whitespace-nowrap shadow-lg">
43
+ <div className="font-medium">{c.username}</div>
44
+ <div className="text-surface-400">{c.permission}</div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export { getColor };
client/src/components/Collaboration/ShareModal.tsx ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { X, Search, UserPlus, Shield, Trash2, Check } from 'lucide-react';
3
+ import { api } from '../../api/client';
4
+ import { useCollaborationStore } from '../../store/collaborationStore';
5
+
6
+ interface ShareModalProps {
7
+ projectId: string;
8
+ isOpen: boolean;
9
+ onClose: () => void;
10
+ }
11
+
12
+ interface UserResult {
13
+ id: string;
14
+ username: string;
15
+ }
16
+
17
+ interface CollaboratorResult {
18
+ id: string;
19
+ username: string;
20
+ permission: string;
21
+ created_at: number;
22
+ }
23
+
24
+ export default function ShareModal({ projectId, isOpen, onClose }: ShareModalProps) {
25
+ const [searchQuery, setSearchQuery] = useState('');
26
+ const [searchResults, setSearchResults] = useState<UserResult[]>([]);
27
+ const [searching, setSearching] = useState(false);
28
+ const [collaborators, setCollaborators] = useState<CollaboratorResult[]>([]);
29
+ const [owner, setOwner] = useState<{ id: string; username: string } | null>(null);
30
+ const [addingUser, setAddingUser] = useState<string | null>(null);
31
+ const [error, setError] = useState<string | null>(null);
32
+
33
+ const connected = useCollaborationStore((s) => s.connected);
34
+
35
+ const fetchCollaborators = useCallback(async () => {
36
+ try {
37
+ const res = await api.request<{ owner: any; collaborators: CollaboratorResult[] }>('GET', `/projects/${projectId}/collaborators`);
38
+ setOwner(res.owner);
39
+ setCollaborators(res.collaborators);
40
+ if (res.owner) {
41
+ useCollaborationStore.getState().setOwner(res.owner);
42
+ }
43
+ } catch {}
44
+ }, [projectId]);
45
+
46
+ useEffect(() => {
47
+ if (isOpen) {
48
+ fetchCollaborators();
49
+ setSearchQuery('');
50
+ setSearchResults([]);
51
+ setError(null);
52
+ }
53
+ }, [isOpen, fetchCollaborators]);
54
+
55
+ useEffect(() => {
56
+ if (searchQuery.length < 2) {
57
+ setSearchResults([]);
58
+ return;
59
+ }
60
+ const timer = setTimeout(async () => {
61
+ setSearching(true);
62
+ try {
63
+ const res = await api.request<{ users: UserResult[] }>('GET', `/projects/collaborators/search?username=${encodeURIComponent(searchQuery)}`);
64
+ setSearchResults(res.users);
65
+ } catch {
66
+ setSearchResults([]);
67
+ } finally {
68
+ setSearching(false);
69
+ }
70
+ }, 300);
71
+ return () => clearTimeout(timer);
72
+ }, [searchQuery]);
73
+
74
+ const handleAddCollaborator = async (username: string) => {
75
+ setAddingUser(username);
76
+ setError(null);
77
+ try {
78
+ await api.request('POST', `/projects/${projectId}/collaborators`, { username, permission: 'edit' });
79
+ await fetchCollaborators();
80
+ setSearchQuery('');
81
+ setSearchResults([]);
82
+ } catch (err: any) {
83
+ setError(err.message || 'Failed to add collaborator');
84
+ } finally {
85
+ setAddingUser(null);
86
+ }
87
+ };
88
+
89
+ const handleUpdatePermission = async (userId: string, permission: string) => {
90
+ try {
91
+ await api.request('PUT', `/projects/${projectId}/collaborators/${userId}`, { permission });
92
+ await fetchCollaborators();
93
+ } catch {}
94
+ };
95
+
96
+ const handleRemoveCollaborator = async (userId: string) => {
97
+ try {
98
+ await api.request('DELETE', `/projects/${projectId}/collaborators/${userId}`);
99
+ await fetchCollaborators();
100
+ } catch {}
101
+ };
102
+
103
+ if (!isOpen) return null;
104
+
105
+ return (
106
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
107
+ <div className="bg-surface-900 border border-surface-700 rounded-xl w-full max-w-lg max-h-[80vh] flex flex-col">
108
+ <div className="flex items-center justify-between px-5 py-4 border-b border-surface-700">
109
+ <div className="flex items-center gap-2">
110
+ <Shield className="w-4 h-4 text-primary-400" />
111
+ <h2 className="text-sm font-semibold text-white">Share Project</h2>
112
+ {connected && (
113
+ <span className="text-xs text-green-400 bg-green-400/10 px-1.5 py-0.5 rounded-full">
114
+ Live
115
+ </span>
116
+ )}
117
+ </div>
118
+ <button onClick={onClose} className="text-surface-400 hover:text-white p-1">
119
+ <X className="w-4 h-4" />
120
+ </button>
121
+ </div>
122
+
123
+ <div className="p-4 border-b border-surface-700">
124
+ <div className="relative">
125
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
126
+ <input
127
+ type="text"
128
+ value={searchQuery}
129
+ onChange={(e) => setSearchQuery(e.target.value)}
130
+ placeholder="Search users by username..."
131
+ className="w-full bg-surface-800 border border-surface-600 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder-surface-500 focus:outline-none focus:border-primary-500"
132
+ />
133
+ {searching && (
134
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
135
+ <div className="animate-spin rounded-full h-3 w-3 border border-primary-500 border-t-transparent" />
136
+ </div>
137
+ )}
138
+ </div>
139
+
140
+ {searchResults.length > 0 && (
141
+ <div className="mt-2 bg-surface-800 border border-surface-700 rounded-lg overflow-hidden">
142
+ {searchResults.map((user) => (
143
+ <button
144
+ key={user.id}
145
+ onClick={() => handleAddCollaborator(user.username)}
146
+ disabled={addingUser === user.username}
147
+ className="w-full flex items-center justify-between px-3 py-2 hover:bg-surface-700 text-sm disabled:opacity-50"
148
+ >
149
+ <span className="text-white">{user.username}</span>
150
+ <span className="flex items-center gap-1 text-primary-400 text-xs">
151
+ {addingUser === user.username ? (
152
+ <>
153
+ <div className="animate-spin rounded-full h-3 w-3 border border-primary-500 border-t-transparent" />
154
+ Adding...
155
+ </>
156
+ ) : (
157
+ <>
158
+ <UserPlus className="w-3.5 h-3.5" />
159
+ Add
160
+ </>
161
+ )}
162
+ </span>
163
+ </button>
164
+ ))}
165
+ </div>
166
+ )}
167
+ </div>
168
+
169
+ {error && (
170
+ <div className="px-4 py-2 bg-red-500/10 text-red-400 text-xs">{error}</div>
171
+ )}
172
+
173
+ <div className="flex-1 overflow-y-auto p-4 space-y-2">
174
+ {owner && (
175
+ <div className="flex items-center justify-between px-3 py-2 bg-surface-800 rounded-lg">
176
+ <div className="flex items-center gap-2">
177
+ <div className="w-7 h-7 rounded-full bg-amber-500/20 flex items-center justify-center">
178
+ <span className="text-xs font-bold text-amber-400">
179
+ {owner.username.charAt(0).toUpperCase()}
180
+ </span>
181
+ </div>
182
+ <div>
183
+ <span className="text-sm text-white font-medium">{owner.username}</span>
184
+ <span className="text-xs text-surface-400 ml-2">Owner</span>
185
+ </div>
186
+ </div>
187
+ <span className="text-xs bg-amber-500/10 text-amber-400 px-2 py-0.5 rounded-full">
188
+ admin
189
+ </span>
190
+ </div>
191
+ )}
192
+
193
+ {collaborators.map((collab) => (
194
+ <div
195
+ key={collab.id}
196
+ className="flex items-center justify-between px-3 py-2 bg-surface-800 rounded-lg"
197
+ >
198
+ <div className="flex items-center gap-2">
199
+ <div className="w-7 h-7 rounded-full bg-primary-500/20 flex items-center justify-center">
200
+ <span className="text-xs font-bold text-primary-400">
201
+ {collab.username.charAt(0).toUpperCase()}
202
+ </span>
203
+ </div>
204
+ <span className="text-sm text-white">{collab.username}</span>
205
+ </div>
206
+
207
+ <div className="flex items-center gap-2">
208
+ <select
209
+ value={collab.permission}
210
+ onChange={(e) => handleUpdatePermission(collab.id, e.target.value)}
211
+ className="bg-surface-700 border border-surface-600 rounded text-xs text-white px-2 py-1 focus:outline-none focus:border-primary-500"
212
+ >
213
+ <option value="view">view</option>
214
+ <option value="edit">edit</option>
215
+ <option value="admin">admin</option>
216
+ </select>
217
+
218
+ <button
219
+ onClick={() => handleRemoveCollaborator(collab.id)}
220
+ className="text-surface-400 hover:text-red-400 p-1"
221
+ title="Remove collaborator"
222
+ >
223
+ <Trash2 className="w-3.5 h-3.5" />
224
+ </button>
225
+ </div>
226
+ </div>
227
+ ))}
228
+
229
+ {!owner && collaborators.length === 0 && (
230
+ <p className="text-surface-500 text-xs text-center py-8">
231
+ No collaborators yet. Search for users to share this project.
232
+ </p>
233
+ )}
234
+ </div>
235
+ </div>
236
+ </div>
237
+ );
238
+ }
client/src/components/ProjectEditor/FileTree.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import React, { useState, useCallback, useEffect, useRef } from 'react';
2
  import { useEditorStore } from '../../store/editorStore';
 
3
  import { ProjectFile } from '../../types/blocks';
4
  import { FrameworkType } from '../../types';
5
  import {
@@ -8,6 +9,7 @@ import {
8
  Link, Unlink, Link2, Globe, X, Copy, PanelRight
9
  } from 'lucide-react';
10
  import { v4 as uuidv4 } from 'uuid';
 
11
 
12
  interface FileTreeProps {
13
  files: ProjectFile[];
@@ -171,6 +173,9 @@ function FileItem({ file, depth, activeFileId, onSelect, onRename, onDelete, onA
171
  const isActive = activeFileId === file.id;
172
  const isFile = file.type !== 'folder';
173
  const linkedIds = file.linkedFiles || [];
 
 
 
174
 
175
  // Get linked files
176
  const linked = linkedIds.map(id => findFile(allFiles, id)).filter(Boolean) as ProjectFile[];
@@ -228,6 +233,20 @@ function FileItem({ file, depth, activeFileId, onSelect, onRename, onDelete, onA
228
  {hasLinks && isFile && (
229
  <span className="text-[9px] text-primary-400/60 bg-primary-500/10 px-1 rounded-full flex-shrink-0">{linkedIds.length}</span>
230
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  <div className="ml-auto flex items-center gap-0.5 opacity-0 group-hover:opacity-100">
232
  {file.type === 'folder' && (
233
  <>
 
1
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
2
  import { useEditorStore } from '../../store/editorStore';
3
+ import { useCollaborationStore } from '../../store/collaborationStore';
4
  import { ProjectFile } from '../../types/blocks';
5
  import { FrameworkType } from '../../types';
6
  import {
 
9
  Link, Unlink, Link2, Globe, X, Copy, PanelRight
10
  } from 'lucide-react';
11
  import { v4 as uuidv4 } from 'uuid';
12
+ import { getColor } from '../Collaboration/CollaboratorAvatars';
13
 
14
  interface FileTreeProps {
15
  files: ProjectFile[];
 
173
  const isActive = activeFileId === file.id;
174
  const isFile = file.type !== 'folder';
175
  const linkedIds = file.linkedFiles || [];
176
+ const collaborators = useCollaborationStore((s) => s.collaborators);
177
+ const connected = useCollaborationStore((s) => s.connected);
178
+ const collabOnFile = useMemo(() => collaborators.filter((c) => c.activeFileId === file.id), [collaborators, file.id]);
179
 
180
  // Get linked files
181
  const linked = linkedIds.map(id => findFile(allFiles, id)).filter(Boolean) as ProjectFile[];
 
233
  {hasLinks && isFile && (
234
  <span className="text-[9px] text-primary-400/60 bg-primary-500/10 px-1 rounded-full flex-shrink-0">{linkedIds.length}</span>
235
  )}
236
+ {connected && collabOnFile.length > 0 && (
237
+ <div className="flex items-center -space-x-1 ml-auto">
238
+ {collabOnFile.slice(0, 3).map((c) => (
239
+ <div
240
+ key={c.userId}
241
+ className="w-4 h-4 rounded-full flex items-center justify-center text-[7px] font-bold text-white ring-1 ring-surface-900"
242
+ style={{ backgroundColor: getColor(c.userId) }}
243
+ title={`${c.username} (${c.permission})`}
244
+ >
245
+ {c.username.charAt(0).toUpperCase()}
246
+ </div>
247
+ ))}
248
+ </div>
249
+ )}
250
  <div className="ml-auto flex items-center gap-0.5 opacity-0 group-hover:opacity-100">
251
  {file.type === 'folder' && (
252
  <>
client/src/components/ProjectEditor/ProjectEditor.tsx CHANGED
@@ -2,16 +2,20 @@ import { useEffect, useState, useCallback, useMemo } from 'react';
2
  import { useParams, useNavigate } from 'react-router-dom';
3
  import { useProjectStore } from '../../store/projectStore';
4
  import { useEditorStore } from '../../store/editorStore';
 
 
5
  import {
6
  Blocks, Eye, Code2, FileType,
7
  Save, ArrowLeft, Grid3X3, Maximize2, Minimize2,
8
- PanelLeft, PanelRight
9
  } from 'lucide-react';
10
  import FileTree from './FileTree';
11
  import Toolbar from './Toolbar';
12
  import BlockEditor from '../BlockEditor/BlockEditor';
13
  import VisualEditor from '../VisualEditor/VisualEditor';
14
  import CodeView from '../CodeView/CodeView';
 
 
15
  import { ProjectFile } from '../../types/blocks';
16
 
17
  type FileCategory = 'markup' | 'script' | 'other';
@@ -58,6 +62,11 @@ export default function ProjectEditor() {
58
  } = useEditorStore();
59
  const [showSidebar, setShowSidebar] = useState(true);
60
  const [saving, setSaving] = useState(false);
 
 
 
 
 
61
 
62
  useEffect(() => {
63
  if (id) loadProject(id);
@@ -147,6 +156,13 @@ export default function ProjectEditor() {
147
  setViewMode('design');
148
  }, [activeFileId, availableModes]);
149
 
 
 
 
 
 
 
 
150
  const handleSave = useCallback(async () => {
151
  if (!currentProject || !id) return;
152
  setSaving(true);
@@ -162,18 +178,24 @@ export default function ProjectEditor() {
162
  blocksXml: currentBlocksXml,
163
  blockCode: currentBlockCode,
164
  };
165
- await updateProject(id, { data });
 
 
 
 
 
 
166
  } finally {
167
  setSaving(false);
168
  }
169
- }, [currentProject, id, fileTree, visualElements, activeFileId, updateProject]);
170
 
 
171
  useEffect(() => {
172
- const interval = setInterval(() => {
173
- if (currentProject && id) handleSave();
174
- }, 30000);
175
- return () => clearInterval(interval);
176
- }, [currentProject, id, handleSave]);
177
 
178
  useEffect(() => {
179
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -253,6 +275,7 @@ export default function ProjectEditor() {
253
  }
254
  rightContent={
255
  <div className="flex items-center gap-2">
 
256
  {displayMode === 'visual' && (
257
  <>
258
  <button
@@ -271,6 +294,13 @@ export default function ProjectEditor() {
271
  </button>
272
  </>
273
  )}
 
 
 
 
 
 
 
274
  <button
275
  onClick={() => setShowSidebar(!showSidebar)}
276
  className="btn-ghost p-1.5"
@@ -319,6 +349,14 @@ export default function ProjectEditor() {
319
  )}
320
  </div>
321
  </div>
 
 
 
 
 
 
 
 
322
  </div>
323
  );
324
  }
 
2
  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 } from '../../hooks/useWebSocket';
7
  import {
8
  Blocks, Eye, Code2, FileType,
9
  Save, ArrowLeft, Grid3X3, Maximize2, Minimize2,
10
+ PanelLeft, PanelRight, Users
11
  } from 'lucide-react';
12
  import FileTree from './FileTree';
13
  import Toolbar from './Toolbar';
14
  import BlockEditor from '../BlockEditor/BlockEditor';
15
  import VisualEditor from '../VisualEditor/VisualEditor';
16
  import CodeView from '../CodeView/CodeView';
17
+ import ShareModal from '../Collaboration/ShareModal';
18
+ import CollaboratorAvatars from '../Collaboration/CollaboratorAvatars';
19
  import { ProjectFile } from '../../types/blocks';
20
 
21
  type FileCategory = 'markup' | 'script' | 'other';
 
62
  } = useEditorStore();
63
  const [showSidebar, setShowSidebar] = useState(true);
64
  const [saving, setSaving] = useState(false);
65
+ const [showShareModal, setShowShareModal] = useState(false);
66
+
67
+ const { emitFileChanged, emitActiveFileChanged, emitSave, emitCursorMove } = useWebSocket(id);
68
+
69
+ const connected = useCollaborationStore((s) => s.connected);
70
 
71
  useEffect(() => {
72
  if (id) loadProject(id);
 
156
  setViewMode('design');
157
  }, [activeFileId, availableModes]);
158
 
159
+ // Emit active file changes to collaborators
160
+ useEffect(() => {
161
+ if (id && activeFileId) {
162
+ emitActiveFileChanged(activeFileId);
163
+ }
164
+ }, [id, activeFileId, emitActiveFileChanged]);
165
+
166
  const handleSave = useCallback(async () => {
167
  if (!currentProject || !id) return;
168
  setSaving(true);
 
178
  blocksXml: currentBlocksXml,
179
  blockCode: currentBlockCode,
180
  };
181
+
182
+ // Try WebSocket save first, fall back to REST
183
+ if (connected) {
184
+ await emitSave(data);
185
+ } else {
186
+ await updateProject(id, { data });
187
+ }
188
  } finally {
189
  setSaving(false);
190
  }
191
+ }, [currentProject, id, fileTree, visualElements, activeFileId, updateProject, connected, emitSave]);
192
 
193
+ // Auto-save via WebSocket when data changes (no more 30s polling)
194
  useEffect(() => {
195
+ if (!currentProject || !id || !connected) return;
196
+ const timer = setTimeout(() => handleSave(), 2000);
197
+ return () => clearTimeout(timer);
198
+ }, [currentProject, id, fileTree, visualElements, activeFileId, connected, handleSave]);
 
199
 
200
  useEffect(() => {
201
  const handleKeyDown = (e: KeyboardEvent) => {
 
275
  }
276
  rightContent={
277
  <div className="flex items-center gap-2">
278
+ <CollaboratorAvatars />
279
  {displayMode === 'visual' && (
280
  <>
281
  <button
 
294
  </button>
295
  </>
296
  )}
297
+ <button
298
+ onClick={() => setShowShareModal(true)}
299
+ className={`btn-ghost p-1.5 ${connected ? 'text-green-400' : 'text-surface-400'}`}
300
+ title={connected ? 'Collaborators online' : 'Share project'}
301
+ >
302
+ <Users className="w-4 h-4" />
303
+ </button>
304
  <button
305
  onClick={() => setShowSidebar(!showSidebar)}
306
  className="btn-ghost p-1.5"
 
349
  )}
350
  </div>
351
  </div>
352
+
353
+ {id && (
354
+ <ShareModal
355
+ projectId={id}
356
+ isOpen={showShareModal}
357
+ onClose={() => setShowShareModal(false)}
358
+ />
359
+ )}
360
  </div>
361
  );
362
  }
client/src/hooks/useWebSocket.ts ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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';
5
+
6
+ let globalSocket: Socket | null = null;
7
+
8
+ export function useWebSocket(projectId: string | undefined) {
9
+ const token = useAuthStore((s) => s.token);
10
+ const {
11
+ setCollaborators,
12
+ addCollaborator,
13
+ removeCollaborator,
14
+ updateActiveFile,
15
+ updateCursor,
16
+ setConnected,
17
+ } = useCollaborationStore();
18
+ const socketRef = useRef<Socket | null>(null);
19
+
20
+ useEffect(() => {
21
+ if (!token || !projectId) return;
22
+
23
+ if (globalSocket?.connected) {
24
+ globalSocket.disconnect();
25
+ }
26
+
27
+ const socket = io({
28
+ auth: { token },
29
+ transports: ['websocket', 'polling'],
30
+ });
31
+
32
+ socketRef.current = socket;
33
+ globalSocket = socket;
34
+
35
+ socket.on('connect', () => {
36
+ setConnected(true);
37
+ socket.emit('join_project', { projectId });
38
+ });
39
+
40
+ socket.on('disconnect', () => {
41
+ setConnected(false);
42
+ });
43
+
44
+ socket.on('collaborator_list', ({ collaborators }: { collaborators: Collaborator[] }) => {
45
+ setCollaborators(collaborators);
46
+ });
47
+
48
+ socket.on('collaborator_joined', (collaborator: Collaborator) => {
49
+ addCollaborator(collaborator);
50
+ });
51
+
52
+ socket.on('collaborator_left', ({ userId }: { userId: string }) => {
53
+ removeCollaborator(userId);
54
+ });
55
+
56
+ socket.on('collaborator_active_file', ({ userId, fileId }: { userId: string; fileId: string | null }) => {
57
+ updateActiveFile(userId, fileId);
58
+ });
59
+
60
+ socket.on('collaborator_cursor', ({ userId, fileId, cursor }: { userId: string; fileId: string; cursor: { line: number; ch: number } | null }) => {
61
+ updateCursor(userId, fileId, cursor);
62
+ });
63
+
64
+ socket.on('file_updated', ({ fileId, content }: { fileId: string; content: string }) => {
65
+ const { updateFile } = useCollaborationStore.getState();
66
+ updateFile(fileId, content);
67
+ });
68
+
69
+ socket.on('project_saved', ({ updatedAt, savedBy }: { updatedAt: number; savedBy: string }) => {
70
+ const { setLastSaved, setSavedBy } = useCollaborationStore.getState();
71
+ setLastSaved(updatedAt);
72
+ setSavedBy(savedBy);
73
+ });
74
+
75
+ return () => {
76
+ socket.emit('leave_project', { projectId });
77
+ socket.disconnect();
78
+ if (globalSocket === socket) globalSocket = null;
79
+ setConnected(false);
80
+ };
81
+ }, [token, projectId, setCollaborators, addCollaborator, removeCollaborator, updateActiveFile, updateCursor, setConnected]);
82
+
83
+ const emitFileChanged = useCallback((fileId: string, content: string) => {
84
+ if (socketRef.current?.connected && projectId) {
85
+ socketRef.current.emit('file_changed', { projectId, fileId, content });
86
+ }
87
+ }, [projectId]);
88
+
89
+ const emitActiveFileChanged = useCallback((fileId: string | null) => {
90
+ if (socketRef.current?.connected && projectId) {
91
+ socketRef.current.emit('active_file_changed', { projectId, fileId });
92
+ }
93
+ }, [projectId]);
94
+
95
+ const emitCursorMove = useCallback((fileId: string, cursor: { line: number; ch: number }) => {
96
+ if (socketRef.current?.connected && projectId) {
97
+ socketRef.current.emit('cursor_move', { projectId, fileId, cursor });
98
+ }
99
+ }, [projectId]);
100
+
101
+ const emitSave = useCallback((data: any): Promise<any> => {
102
+ return new Promise((resolve, reject) => {
103
+ if (!socketRef.current?.connected || !projectId) {
104
+ reject(new Error('Not connected'));
105
+ return;
106
+ }
107
+ socketRef.current.emit('save_project', { projectId, data }, (response: any) => {
108
+ if (response?.error) reject(new Error(response.error));
109
+ else resolve(response);
110
+ });
111
+ });
112
+ }, [projectId]);
113
+
114
+ return {
115
+ socket: socketRef.current,
116
+ emitFileChanged,
117
+ emitActiveFileChanged,
118
+ emitCursorMove,
119
+ emitSave,
120
+ };
121
+ }
122
+
123
+ export function getSocket(): Socket | null {
124
+ return globalSocket;
125
+ }
client/src/store/collaborationStore.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand';
2
+ import { useEditorStore } from './editorStore';
3
+
4
+ export interface Collaborator {
5
+ userId: string;
6
+ username: string;
7
+ permission: 'view' | 'edit' | 'admin';
8
+ activeFileId: string | null;
9
+ cursor: { line: number; ch: number } | null;
10
+ joinedAt: number;
11
+ }
12
+
13
+ interface CollaboratorOwner {
14
+ id: string;
15
+ username: string;
16
+ }
17
+
18
+ interface CollaborationStore {
19
+ connected: boolean;
20
+ collaborators: Collaborator[];
21
+ owner: CollaboratorOwner | null;
22
+ lastSaved: number | null;
23
+ savedBy: string | null;
24
+ setConnected: (connected: boolean) => void;
25
+ setCollaborators: (collaborators: Collaborator[]) => void;
26
+ setOwner: (owner: CollaboratorOwner) => void;
27
+ addCollaborator: (collaborator: Collaborator) => void;
28
+ removeCollaborator: (userId: string) => void;
29
+ updateActiveFile: (userId: string, fileId: string | null) => void;
30
+ updateCursor: (userId: string, fileId: string, cursor: { line: number; ch: number } | null) => void;
31
+ updateFile: (fileId: string, content: string) => void;
32
+ setLastSaved: (timestamp: number) => void;
33
+ setSavedBy: (username: string) => void;
34
+ getCollaboratorsOnFile: (fileId: string) => Collaborator[];
35
+ isViewOnly: () => boolean;
36
+ }
37
+
38
+ export const useCollaborationStore = create<CollaborationStore>((set, get) => ({
39
+ connected: false,
40
+ collaborators: [],
41
+ owner: null,
42
+ lastSaved: null,
43
+ savedBy: null,
44
+
45
+ setConnected: (connected) => set({ connected }),
46
+
47
+ setCollaborators: (collaborators) => set({ collaborators }),
48
+
49
+ setOwner: (owner) => set({ owner }),
50
+
51
+ addCollaborator: (collaborator) =>
52
+ set((state) => {
53
+ const exists = state.collaborators.find((c) => c.userId === collaborator.userId);
54
+ if (exists) return state;
55
+ return { collaborators: [...state.collaborators, collaborator] };
56
+ }),
57
+
58
+ removeCollaborator: (userId) =>
59
+ set((state) => ({
60
+ collaborators: state.collaborators.filter((c) => c.userId !== userId),
61
+ })),
62
+
63
+ updateActiveFile: (userId, fileId) =>
64
+ set((state) => ({
65
+ collaborators: state.collaborators.map((c) =>
66
+ c.userId === userId ? { ...c, activeFileId: fileId } : c
67
+ ),
68
+ })),
69
+
70
+ updateCursor: (userId, fileId, cursor) =>
71
+ set((state) => ({
72
+ collaborators: state.collaborators.map((c) =>
73
+ c.userId === userId ? { ...c, cursor, activeFileId: fileId } : c
74
+ ),
75
+ })),
76
+
77
+ updateFile: (fileId, content) => {
78
+ const state = useEditorStore.getState();
79
+ if (state.activeFileId === fileId) {
80
+ state.setActiveFileContent(content);
81
+ }
82
+ state.updateFile(fileId, { content });
83
+ },
84
+
85
+ setLastSaved: (timestamp) => set({ lastSaved: timestamp }),
86
+
87
+ setSavedBy: (username) => set({ savedBy: username }),
88
+
89
+ getCollaboratorsOnFile: (fileId) => {
90
+ return get().collaborators.filter((c) => c.activeFileId === fileId);
91
+ },
92
+
93
+ isViewOnly: () => {
94
+ const { user } = require('../store/authStore').useAuthStore.getState();
95
+ const collab = get().collaborators.find((c) => c.userId === user?.id);
96
+ return collab?.permission === 'view';
97
+ },
98
+ }));
server/package.json CHANGED
@@ -20,6 +20,7 @@
20
  "jsonwebtoken": "^9.0.2",
21
  "multer": "^1.4.5-lts.1",
22
  "nodemailer": "^6.9.7",
 
23
  "uuid": "^9.0.0",
24
  "zod": "^3.22.4"
25
  },
 
20
  "jsonwebtoken": "^9.0.2",
21
  "multer": "^1.4.5-lts.1",
22
  "nodemailer": "^6.9.7",
23
+ "socket.io": "^4.7.5",
24
  "uuid": "^9.0.0",
25
  "zod": "^3.22.4"
26
  },
server/src/database/index.ts CHANGED
@@ -131,9 +131,23 @@ function initializeSchema(): void {
131
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
132
  );
133
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
135
  CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
136
  CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
 
 
137
  `);
138
  }
139
 
 
131
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
132
  );
133
 
134
+ CREATE TABLE IF NOT EXISTS project_collaborators (
135
+ project_id TEXT NOT NULL,
136
+ user_id TEXT NOT NULL,
137
+ permission TEXT NOT NULL DEFAULT 'edit' CHECK(permission IN ('view','edit','admin')),
138
+ added_by TEXT NOT NULL,
139
+ created_at INTEGER NOT NULL,
140
+ PRIMARY KEY (project_id, user_id),
141
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
142
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
143
+ FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE CASCADE
144
+ );
145
+
146
  CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
147
  CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
148
  CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
149
+ CREATE INDEX IF NOT EXISTS idx_collaborators_project ON project_collaborators(project_id);
150
+ CREATE INDEX IF NOT EXISTS idx_collaborators_user ON project_collaborators(user_id);
151
  `);
152
  }
153
 
server/src/index.ts CHANGED
@@ -2,9 +2,11 @@ import express from 'express';
2
  import cors from 'cors';
3
  import helmet from 'helmet';
4
  import path from 'path';
 
5
  import { config } from './config';
6
  import { getDatabase, closeDatabase } from './database';
7
  import { apiLimiter } from './middleware/rateLimiter';
 
8
  import authRoutes from './routes/auth';
9
  import projectRoutes from './routes/projects';
10
 
@@ -43,7 +45,10 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
43
 
44
  getDatabase();
45
 
46
- const server = app.listen(config.port, () => {
 
 
 
47
  console.log(`RealBlocks server running on port ${config.port}`);
48
  console.log(`Environment: ${config.nodeEnv}`);
49
  });
 
2
  import cors from 'cors';
3
  import helmet from 'helmet';
4
  import path from 'path';
5
+ import http from 'http';
6
  import { config } from './config';
7
  import { getDatabase, closeDatabase } from './database';
8
  import { apiLimiter } from './middleware/rateLimiter';
9
+ import { initRealtime } from './services/realtime';
10
  import authRoutes from './routes/auth';
11
  import projectRoutes from './routes/projects';
12
 
 
45
 
46
  getDatabase();
47
 
48
+ const httpServer = http.createServer(app);
49
+ initRealtime(httpServer);
50
+
51
+ const server = httpServer.listen(config.port, () => {
52
  console.log(`RealBlocks server running on port ${config.port}`);
53
  console.log(`Environment: ${config.nodeEnv}`);
54
  });
server/src/routes/projects.ts CHANGED
@@ -31,13 +31,19 @@ router.get('/', authenticate, (req: AuthRequest, res: Response) => {
31
 
32
  router.get('/:id', authenticate, (req: AuthRequest, res: Response) => {
33
  const db = getDatabase();
34
- const project = db.prepare(
35
  'SELECT * FROM projects WHERE id = ? AND user_id = ?'
36
  ).get(req.params.id, req.userId) as any;
37
 
38
  if (!project) {
39
- res.status(404).json({ error: 'Project not found' });
40
- return;
 
 
 
 
 
 
41
  }
42
 
43
  try {
@@ -101,13 +107,21 @@ router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
101
  const updates = updateProjectSchema.parse(req.body);
102
  const db = getDatabase();
103
 
104
- const project = db.prepare(
105
  'SELECT * FROM projects WHERE id = ? AND user_id = ?'
106
  ).get(req.params.id, req.userId) as any;
107
 
 
108
  if (!project) {
109
- res.status(404).json({ error: 'Project not found' });
110
- return;
 
 
 
 
 
 
 
111
  }
112
 
113
  const now = Date.now();
@@ -126,15 +140,14 @@ router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
126
  encrypted_data = ?,
127
  encryption_iv = ?,
128
  updated_at = ?
129
- WHERE id = ? AND user_id = ?
130
  `).run(
131
  updates.name || null,
132
  updates.description !== undefined ? updates.description : null,
133
  encryptedData,
134
  encryptedIv,
135
  now,
136
- req.params.id,
137
- req.userId
138
  );
139
 
140
  res.json({ message: 'Project updated successfully', updated_at: now });
@@ -148,17 +161,23 @@ router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
148
  }
149
  });
150
 
151
- router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
152
  const db = getDatabase();
153
- const result = db.prepare(
154
- 'DELETE FROM projects WHERE id = ? AND user_id = ?'
155
- ).run(req.params.id, req.userId);
156
 
157
- if (result.changes === 0) {
158
  res.status(404).json({ error: 'Project not found' });
159
  return;
160
  }
161
 
 
 
 
 
 
 
162
  res.json({ message: 'Project deleted successfully' });
163
  });
164
 
@@ -186,6 +205,229 @@ router.post('/:id/sync', authenticate, async (req: AuthRequest, res: Response) =
186
  }
187
  });
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  function getDefaultProjectData(framework: string): any {
190
  const base = {
191
  files: [] as any[],
 
31
 
32
  router.get('/:id', authenticate, (req: AuthRequest, res: Response) => {
33
  const db = getDatabase();
34
+ let project = db.prepare(
35
  'SELECT * FROM projects WHERE id = ? AND user_id = ?'
36
  ).get(req.params.id, req.userId) as any;
37
 
38
  if (!project) {
39
+ const collab = db.prepare(
40
+ 'SELECT p.* FROM projects p JOIN project_collaborators c ON p.id = c.project_id WHERE p.id = ? AND c.user_id = ?'
41
+ ).get(req.params.id, req.userId) as any;
42
+ if (!collab) {
43
+ res.status(404).json({ error: 'Project not found' });
44
+ return;
45
+ }
46
+ project = collab;
47
  }
48
 
49
  try {
 
107
  const updates = updateProjectSchema.parse(req.body);
108
  const db = getDatabase();
109
 
110
+ let project = db.prepare(
111
  'SELECT * FROM projects WHERE id = ? AND user_id = ?'
112
  ).get(req.params.id, req.userId) as any;
113
 
114
+ let permission: string | null = 'admin';
115
  if (!project) {
116
+ const collab = db.prepare(
117
+ 'SELECT p.*, c.permission FROM projects p JOIN project_collaborators c ON p.id = c.project_id WHERE p.id = ? AND c.user_id = ?'
118
+ ).get(req.params.id, req.userId) as any;
119
+ if (!collab || collab.permission === 'view') {
120
+ res.status(403).json({ error: 'Permission denied' });
121
+ return;
122
+ }
123
+ project = collab;
124
+ permission = collab.permission;
125
  }
126
 
127
  const now = Date.now();
 
140
  encrypted_data = ?,
141
  encryption_iv = ?,
142
  updated_at = ?
143
+ WHERE id = ?
144
  `).run(
145
  updates.name || null,
146
  updates.description !== undefined ? updates.description : null,
147
  encryptedData,
148
  encryptedIv,
149
  now,
150
+ req.params.id
 
151
  );
152
 
153
  res.json({ message: 'Project updated successfully', updated_at: now });
 
161
  }
162
  });
163
 
164
+ router.delete('/:id', authenticate, (req: AuthRequest, res: Response) => {
165
  const db = getDatabase();
166
+ const project = db.prepare(
167
+ 'SELECT user_id FROM projects WHERE id = ?'
168
+ ).get(req.params.id) as any;
169
 
170
+ if (!project) {
171
  res.status(404).json({ error: 'Project not found' });
172
  return;
173
  }
174
 
175
+ if (project.user_id !== req.userId) {
176
+ res.status(403).json({ error: 'Only the owner can delete a project' });
177
+ return;
178
+ }
179
+
180
+ db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
181
  res.json({ message: 'Project deleted successfully' });
182
  });
183
 
 
205
  }
206
  });
207
 
208
+ // ---- COLLABORATOR ROUTES ----
209
+
210
+ // Search users by username (for share modal) — must be BEFORE /:id routes
211
+ router.get('/collaborators/search', authenticate, (req: AuthRequest, res: Response) => {
212
+ const query = req.query.username as string;
213
+ if (!query || query.length < 2) {
214
+ res.json({ users: [] });
215
+ return;
216
+ }
217
+ const db = getDatabase();
218
+ const users = db.prepare(
219
+ 'SELECT id, username FROM users WHERE username LIKE ? AND id != ? LIMIT 20'
220
+ ).all(`%${query}%`, req.userId);
221
+ res.json({ users });
222
+ });
223
+
224
+ // List collaborators for a project
225
+ router.get('/:id/collaborators', authenticate, (req: AuthRequest, res: Response) => {
226
+ const db = getDatabase();
227
+ const project = db.prepare(
228
+ 'SELECT user_id FROM projects WHERE id = ? AND user_id = ?'
229
+ ).get(req.params.id, req.userId) as any;
230
+
231
+ let hasAccess = !!project;
232
+ if (!hasAccess) {
233
+ const collab = db.prepare(
234
+ 'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
235
+ ).get(req.params.id, req.userId) as any;
236
+ hasAccess = !!collab;
237
+ }
238
+
239
+ if (!hasAccess) {
240
+ res.status(404).json({ error: 'Project not found' });
241
+ return;
242
+ }
243
+
244
+ const owner = db.prepare(
245
+ 'SELECT id, username FROM users WHERE id = (SELECT user_id FROM projects WHERE id = ?)'
246
+ ).get(req.params.id) as any;
247
+
248
+ const collaborators = db.prepare(`
249
+ SELECT u.id, u.username, c.permission, c.created_at
250
+ FROM project_collaborators c
251
+ JOIN users u ON u.id = c.user_id
252
+ WHERE c.project_id = ?
253
+ ORDER BY c.created_at ASC
254
+ `).all(req.params.id);
255
+
256
+ res.json({
257
+ owner: owner ? { id: owner.id, username: owner.username } : null,
258
+ collaborators,
259
+ });
260
+ });
261
+
262
+ // Add collaborator
263
+ router.post('/:id/collaborators', authenticate, async (req: AuthRequest, res: Response) => {
264
+ try {
265
+ const schema = z.object({
266
+ username: z.string().min(1),
267
+ permission: z.enum(['view', 'edit', 'admin']).default('edit'),
268
+ });
269
+ const { username, permission } = schema.parse(req.body);
270
+ const db = getDatabase();
271
+
272
+ const project = db.prepare(
273
+ 'SELECT user_id FROM projects WHERE id = ?'
274
+ ).get(req.params.id) as any;
275
+
276
+ if (!project) {
277
+ res.status(404).json({ error: 'Project not found' });
278
+ return;
279
+ }
280
+
281
+ const isOwner = project.user_id === req.userId;
282
+ let isAdmin = isOwner;
283
+ if (!isOwner) {
284
+ const collab = db.prepare(
285
+ 'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
286
+ ).get(req.params.id, req.userId) as any;
287
+ isAdmin = collab?.permission === 'admin';
288
+ }
289
+
290
+ if (!isAdmin) {
291
+ res.status(403).json({ error: 'Only the owner or admin can add collaborators' });
292
+ return;
293
+ }
294
+
295
+ const targetUser = db.prepare(
296
+ 'SELECT id, username FROM users WHERE username = ?'
297
+ ).get(username) as any;
298
+
299
+ if (!targetUser) {
300
+ res.status(404).json({ error: 'User not found' });
301
+ return;
302
+ }
303
+
304
+ if (targetUser.id === project.user_id) {
305
+ res.status(400).json({ error: 'Cannot add the project owner as a collaborator' });
306
+ return;
307
+ }
308
+
309
+ const existing = db.prepare(
310
+ 'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
311
+ ).get(req.params.id, targetUser.id) as any;
312
+
313
+ if (existing) {
314
+ res.status(400).json({ error: 'User is already a collaborator' });
315
+ return;
316
+ }
317
+
318
+ db.prepare(`
319
+ INSERT INTO project_collaborators (project_id, user_id, permission, added_by, created_at)
320
+ VALUES (?, ?, ?, ?, ?)
321
+ `).run(req.params.id, targetUser.id, permission, req.userId, Date.now());
322
+
323
+ res.status(201).json({
324
+ collaborator: {
325
+ id: targetUser.id,
326
+ username: targetUser.username,
327
+ permission,
328
+ },
329
+ });
330
+ } catch (error: any) {
331
+ if (error instanceof z.ZodError) {
332
+ res.status(400).json({ error: 'Invalid input', details: error.errors });
333
+ return;
334
+ }
335
+ console.error('Add collaborator error:', error);
336
+ res.status(500).json({ error: 'Internal server error' });
337
+ }
338
+ });
339
+
340
+ // Update collaborator permission
341
+ router.put('/:id/collaborators/:userId', authenticate, async (req: AuthRequest, res: Response) => {
342
+ try {
343
+ const schema = z.object({
344
+ permission: z.enum(['view', 'edit', 'admin']),
345
+ });
346
+ const { permission } = schema.parse(req.body);
347
+ const db = getDatabase();
348
+
349
+ const project = db.prepare(
350
+ 'SELECT user_id FROM projects WHERE id = ?'
351
+ ).get(req.params.id) as any;
352
+
353
+ if (!project) {
354
+ res.status(404).json({ error: 'Project not found' });
355
+ return;
356
+ }
357
+
358
+ const isOwner = project.user_id === req.userId;
359
+ let isAdmin = isOwner;
360
+ if (!isOwner) {
361
+ const collab = db.prepare(
362
+ 'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
363
+ ).get(req.params.id, req.userId) as any;
364
+ isAdmin = collab?.permission === 'admin';
365
+ }
366
+
367
+ if (!isAdmin) {
368
+ res.status(403).json({ error: 'Permission denied' });
369
+ return;
370
+ }
371
+
372
+ const result = db.prepare(`
373
+ UPDATE project_collaborators SET permission = ? WHERE project_id = ? AND user_id = ?
374
+ `).run(permission, req.params.id, req.params.userId);
375
+
376
+ if (result.changes === 0) {
377
+ res.status(404).json({ error: 'Collaborator not found' });
378
+ return;
379
+ }
380
+
381
+ res.json({ message: 'Permission updated', permission });
382
+ } catch (error: any) {
383
+ if (error instanceof z.ZodError) {
384
+ res.status(400).json({ error: 'Invalid input', details: error.errors });
385
+ return;
386
+ }
387
+ res.status(500).json({ error: 'Internal server error' });
388
+ }
389
+ });
390
+
391
+ // Remove collaborator
392
+ router.delete('/:id/collaborators/:userId', authenticate, (req: AuthRequest, res: Response) => {
393
+ const db = getDatabase();
394
+
395
+ const project = db.prepare(
396
+ 'SELECT user_id FROM projects WHERE id = ?'
397
+ ).get(req.params.id) as any;
398
+
399
+ if (!project) {
400
+ res.status(404).json({ error: 'Project not found' });
401
+ return;
402
+ }
403
+
404
+ const isOwner = project.user_id === req.userId;
405
+ const isSelf = req.userId === req.params.userId;
406
+ let isAdmin = isOwner;
407
+ if (!isOwner && !isSelf) {
408
+ const collab = db.prepare(
409
+ 'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
410
+ ).get(req.params.id, req.userId) as any;
411
+ isAdmin = collab?.permission === 'admin';
412
+ }
413
+
414
+ if (!isOwner && !isAdmin && !isSelf) {
415
+ res.status(403).json({ error: 'Permission denied' });
416
+ return;
417
+ }
418
+
419
+ const result = db.prepare(
420
+ 'DELETE FROM project_collaborators WHERE project_id = ? AND user_id = ?'
421
+ ).run(req.params.id, req.params.userId);
422
+
423
+ if (result.changes === 0) {
424
+ res.status(404).json({ error: 'Collaborator not found' });
425
+ return;
426
+ }
427
+
428
+ res.json({ message: 'Collaborator removed' });
429
+ });
430
+
431
  function getDefaultProjectData(framework: string): any {
432
  const base = {
433
  files: [] as any[],
server/src/services/realtime.ts ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Server as HttpServer } from 'http';
2
+ import { Server, Socket } from 'socket.io';
3
+ import jwt from 'jsonwebtoken';
4
+ import { config } from '../config';
5
+ import { getDatabase } from '../database';
6
+ import { encryptCombined } from './encryption';
7
+
8
+ interface AuthPayload {
9
+ userId: string;
10
+ username: string;
11
+ email: string;
12
+ }
13
+
14
+ interface CollaborationSession {
15
+ userId: string;
16
+ username: string;
17
+ projectId: string;
18
+ socketId: string;
19
+ activeFileId: string | null;
20
+ cursor: { line: number; ch: number } | null;
21
+ joinedAt: number;
22
+ permission: 'view' | 'edit' | 'admin';
23
+ }
24
+
25
+ const sessions = new Map<string, CollaborationSession>(); // socketId -> session
26
+ const projectRooms = new Map<string, Map<string, CollaborationSession[]>>(); // projectId -> { userId -> sessions[] }
27
+
28
+ let io: Server;
29
+
30
+ export function initRealtime(httpServer: HttpServer): Server {
31
+ io = new Server(httpServer, {
32
+ cors: {
33
+ origin: config.corsOrigin,
34
+ credentials: true,
35
+ methods: ['GET', 'POST'],
36
+ },
37
+ pingInterval: 25000,
38
+ pingTimeout: 20000,
39
+ });
40
+
41
+ // Auth middleware for socket connections
42
+ io.use((socket, next) => {
43
+ const token = socket.handshake.auth?.token || socket.handshake.query?.token;
44
+ if (!token) {
45
+ return next(new Error('Authentication required'));
46
+ }
47
+ try {
48
+ const decoded = jwt.verify(token as string, config.jwtSecret) as { userId: string; email: string };
49
+ const db = getDatabase();
50
+ const user = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(decoded.userId) as any;
51
+ if (!user) return next(new Error('User not found'));
52
+ (socket as any).user = { userId: user.id, username: user.username, email: user.email };
53
+ next();
54
+ } catch {
55
+ next(new Error('Invalid token'));
56
+ }
57
+ });
58
+
59
+ io.on('connection', (socket: Socket) => {
60
+ const user = (socket as any).user as AuthPayload;
61
+
62
+ // ---- PROJECT ROOM JOINING ----
63
+ socket.on('join_project', ({ projectId }: { projectId: string }) => {
64
+ if (!projectId) return;
65
+
66
+ // Check permission
67
+ const db = getDatabase();
68
+ const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(projectId) as any;
69
+ if (!project) return;
70
+
71
+ let permission: 'view' | 'edit' | 'admin' = 'view';
72
+ if (project.user_id === user.userId) {
73
+ permission = 'admin';
74
+ } else {
75
+ const collab = db.prepare(
76
+ 'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?'
77
+ ).get(projectId, user.userId) as any;
78
+ if (!collab) return; // not a collaborator
79
+ permission = collab.permission;
80
+ }
81
+
82
+ socket.join(`project:${projectId}`);
83
+
84
+ // Track session
85
+ const session: CollaborationSession = {
86
+ userId: user.userId,
87
+ username: user.username,
88
+ projectId,
89
+ socketId: socket.id,
90
+ activeFileId: null,
91
+ cursor: null,
92
+ joinedAt: Date.now(),
93
+ permission,
94
+ };
95
+ sessions.set(socket.id, session);
96
+
97
+ if (!projectRooms.has(projectId)) {
98
+ projectRooms.set(projectId, new Map());
99
+ }
100
+ const room = projectRooms.get(projectId)!;
101
+ if (!room.has(user.userId)) {
102
+ room.set(user.userId, []);
103
+ }
104
+ room.get(user.userId)!.push(session);
105
+
106
+ // Notify others
107
+ socket.to(`project:${projectId}`).emit('collaborator_joined', {
108
+ userId: user.userId,
109
+ username: user.username,
110
+ permission,
111
+ joinedAt: session.joinedAt,
112
+ });
113
+
114
+ // Send current collaborators to the joining user
115
+ const collaboratorList: any[] = [];
116
+ for (const [uid, sessions] of room) {
117
+ for (const s of sessions) {
118
+ collaboratorList.push({
119
+ userId: uid,
120
+ username: s.username,
121
+ permission: s.permission,
122
+ activeFileId: s.activeFileId,
123
+ cursor: s.cursor,
124
+ joinedAt: s.joinedAt,
125
+ });
126
+ }
127
+ }
128
+ socket.emit('collaborator_list', { collaborators: collaboratorList });
129
+ });
130
+
131
+ // ---- LEAVE PROJECT ----
132
+ socket.on('leave_project', ({ projectId }: { projectId: string }) => {
133
+ leaveProject(socket, projectId);
134
+ });
135
+
136
+ // ---- FILE CHANGE ----
137
+ socket.on('file_changed', (data: { projectId: string; fileId: string; content: string }) => {
138
+ const session = sessions.get(socket.id);
139
+ if (!session || session.permission === 'view') return;
140
+ socket.to(`project:${data.projectId}`).emit('file_updated', {
141
+ fileId: data.fileId,
142
+ content: data.content,
143
+ updatedBy: user.userId,
144
+ updatedByUsername: user.username,
145
+ });
146
+ });
147
+
148
+ // ---- ACTIVE FILE CHANGE ----
149
+ socket.on('active_file_changed', (data: { projectId: string; fileId: string | null }) => {
150
+ const session = sessions.get(socket.id);
151
+ if (session) {
152
+ session.activeFileId = data.fileId;
153
+ }
154
+ socket.to(`project:${data.projectId}`).emit('collaborator_active_file', {
155
+ userId: user.userId,
156
+ username: user.username,
157
+ fileId: data.fileId,
158
+ });
159
+ });
160
+
161
+ // ---- CURSOR MOVE ----
162
+ socket.on('cursor_move', (data: { projectId: string; fileId: string; cursor: { line: number; ch: number } }) => {
163
+ const session = sessions.get(socket.id);
164
+ if (session) {
165
+ session.cursor = data.cursor;
166
+ session.activeFileId = data.fileId;
167
+ }
168
+ socket.to(`project:${data.projectId}`).emit('collaborator_cursor', {
169
+ userId: user.userId,
170
+ username: user.username,
171
+ fileId: data.fileId,
172
+ cursor: data.cursor,
173
+ });
174
+ });
175
+
176
+ // ---- SAVE PROJECT (via WS instead of REST) ----
177
+ socket.on('save_project', (data: { projectId: string; data: any }, callback?: (response: any) => void) => {
178
+ const session = sessions.get(socket.id);
179
+ if (!session || session.permission === 'view') {
180
+ if (callback) callback({ error: 'Permission denied' });
181
+ return;
182
+ }
183
+ saveProjectData(session, data.projectId, data.data, callback);
184
+ });
185
+
186
+ // ---- DISCONNECT ----
187
+ socket.on('disconnect', () => {
188
+ const session = sessions.get(socket.id);
189
+ if (session) {
190
+ leaveProject(socket, session.projectId);
191
+ sessions.delete(socket.id);
192
+ }
193
+ });
194
+ });
195
+
196
+ return io;
197
+ }
198
+
199
+ function leaveProject(socket: Socket, projectId: string) {
200
+ const room = projectRooms.get(projectId);
201
+ if (room) {
202
+ const userSessions = room.get((socket as any).user?.userId);
203
+ if (userSessions) {
204
+ const idx = userSessions.findIndex(s => s.socketId === socket.id);
205
+ if (idx !== -1) userSessions.splice(idx, 1);
206
+ if (userSessions.length === 0) room.delete((socket as any).user?.userId);
207
+ }
208
+ if (room.size === 0) projectRooms.delete(projectId);
209
+ }
210
+ socket.leave(`project:${projectId}`);
211
+ sessions.delete(socket.id);
212
+ socket.to(`project:${projectId}`).emit('collaborator_left', {
213
+ userId: (socket as any).user?.userId,
214
+ });
215
+ }
216
+
217
+ function saveProjectData(session: CollaborationSession, projectId: string, data: any, callback?: (response: any) => void) {
218
+ try {
219
+ const db = getDatabase();
220
+ const encryptedData = encryptCombined(JSON.stringify(data));
221
+ const now = Date.now();
222
+ db.prepare(
223
+ 'UPDATE projects SET encrypted_data = ?, encryption_iv = ?, updated_at = ? WHERE id = ?'
224
+ ).run(encryptedData, '', now, projectId);
225
+
226
+ io.to(`project:${projectId}`).emit('project_saved', {
227
+ updatedAt: now,
228
+ savedBy: session.username,
229
+ });
230
+
231
+ if (callback) callback({ success: true, updatedAt: now });
232
+ } catch (error: any) {
233
+ console.error('Save project error:', error);
234
+ if (callback) callback({ error: 'Save failed' });
235
+ }
236
+ }
237
+
238
+ export function getIO(): Server {
239
+ return io;
240
+ }