UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

1,435 lines (1,285 loc) 82.8 kB
import React, { useState, useEffect } from 'react'; import { Users,ArrowLeft, Send, Plus, FileText, X, Check, Clock, Edit, Trash2, Eye, UserPlus } from 'lucide-react'; import io from 'socket.io-client'; const CollaborationApp = ({ user, getAllUsers }) => { const [projects, setProjects] = useState([]); const [invitations, setInvitations] = useState([]); const [activeProject, setActiveProject] = useState(null); const [allUsers, setAllUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); // Modals const [showCreateForm, setShowCreateForm] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false); const [showMembersModal, setShowMembersModal] = useState(false); const [showChangesModal, setShowChangesModal] = useState(false); // Form states const [newProject, setNewProject] = useState({ name: '', description: '' }); const [selectedProject, setSelectedProject] = useState(null); const [selectedUsers, setSelectedUsers] = useState([]); const [pendingChanges, setPendingChanges] = useState([]); useEffect(() => { loadData(); }, []); const apiCall = async (url, options = {}) => { const token = localStorage.getItem('authToken'); const response = await fetch(`http://localhost:3000${url}`, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, ...options.headers }, ...options }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Request failed'); } return response.json(); }; const loadData = async () => { try { setLoading(true); setError(''); const [projectsRes, invitationsRes] = await Promise.all([ apiCall('/projects'), apiCall('/invitations') ]); setProjects(projectsRes.projects || []); setInvitations(invitationsRes.invitations || []); if (user?.role === 'superadmin') { const usersRes = await getAllUsers(); setAllUsers(usersRes.users.filter(u => u.id !== user.id)); } } catch (error) { setError('Failed to load data: ' + error.message); } finally { setLoading(false); } }; const showMessage = (message, isError = false) => { if (isError) { setError(message); setTimeout(() => setError(''), 5000); } else { setSuccess(message); setTimeout(() => setSuccess(''), 3000); } }; const createProject = async (e) => { e.preventDefault(); if (!newProject.name.trim() || !newProject.description.trim()) { showMessage('Please fill in all fields', true); return; } try { setLoading(true); await apiCall('/projects', { method: 'POST', body: JSON.stringify(newProject) }); setNewProject({ name: '', description: '' }); setShowCreateForm(false); showMessage('Project created successfully!'); loadData(); } catch (error) { showMessage('Failed to create project: ' + error.message, true); } finally { setLoading(false); } }; const openInviteModal = (project) => { setSelectedProject(project); setSelectedUsers([]); setShowInviteModal(true); }; const inviteUsers = async () => { if (!selectedProject || selectedUsers.length === 0) { showMessage('Please select users to invite', true); return; } try { setLoading(true); const response = await apiCall(`/projects/${selectedProject.id}/invite`, { method: 'POST', body: JSON.stringify({ userIds: selectedUsers }) }); setShowInviteModal(false); setSelectedUsers([]); setSelectedProject(null); showMessage(`${response.invitations} invitations sent successfully!`); loadData(); } catch (error) { showMessage('Failed to send invitations: ' + error.message, true); } finally { setLoading(false); } }; const respondToInvitation = async (invitationId, accept) => { try { setLoading(true); const response = await apiCall(`/invitations/${invitationId}`, { method: 'PUT', body: JSON.stringify({ accept }) }); showMessage(response.message); loadData(); } catch (error) { showMessage('Failed to respond to invitation: ' + error.message, true); } finally { setLoading(false); } }; const openProject = async (project) => { try { setLoading(true); const response = await apiCall(`/projects/${project.id}`); setActiveProject(response.project); } catch (error) { showMessage('Failed to open project: ' + error.message, true); } finally { setLoading(false); } }; const showProjectMembers = (project) => { setSelectedProject(project); setShowMembersModal(true); }; const loadPendingChanges = async () => { try { setLoading(true); const allChanges = []; for (const project of projects) { try { const response = await apiCall(`/projects/${project.id}/staged-changes`); if (response.success && response.changes?.length > 0) { allChanges.push(...response.changes.map(change => ({ ...change, projectName: project.name, projectId: project.id }))); } } catch (error) { // No pending changes for this project } } setPendingChanges(allChanges); setShowChangesModal(true); } catch (error) { showMessage('Failed to load pending changes: ' + error.message, true); } finally { setLoading(false); } }; const reviewChange = async (changeId, approve) => { try { setLoading(true); const response = await apiCall(`/staged-changes/${changeId}`, { method: 'PUT', body: JSON.stringify({ approve }) }); setPendingChanges(prev => prev.filter(change => change.id !== changeId)); showMessage(response.message); loadData(); } catch (error) { showMessage('Failed to review changes: ' + error.message, true); } finally { setLoading(false); } }; if (activeProject) { return <ProjectEditor project={activeProject} user={user} onBack={() => setActiveProject(null)} />; } return ( <div className="page-container"> <div className="container"> {/* Header */} <div className="header"> <div className="header-content"> <div> <h1 className="header-title">Collaboration Hub</h1> <p className="header-subtitle">Real-time project collaboration platform</p> <div className="flex items-center gap-3 mt-4"> <span className="badge badge-primary"> {projects.length} Projects </span> {invitations.length > 0 && ( <span className="badge badge-warning"> {invitations.length} Pending Invitations </span> )} {user?.role === 'superadmin' && ( <span className="badge badge-danger"> Super Admin </span> )} </div> </div> {user?.role === 'superadmin' && ( <div className="flex gap-3"> <button onClick={() => setShowCreateForm(true)} disabled={loading} className="btn btn-primary" > <Plus className="w-4 h-4" /> New Project </button> <button onClick={loadPendingChanges} disabled={loading} className="btn btn-warning" > <Clock className="w-4 h-4" /> Review Changes </button> </div> )} </div> </div> {/* Messages */} {error && ( <div className="notification notification-error"> {error} </div> )} {success && ( <div className="notification notification-success"> {success} </div> )} {/* Invitations */} {invitations.length > 0 && ( <div className="mb-8"> <h2 className="text-xl font-semibold mb-4 flex items-center gap-2"> <UserPlus className="w-5 h-5 text-orange-600" /> Project Invitations ({invitations.length}) </h2> <div className="grid gap-4"> {invitations.map((inv) => ( <div key={inv.id} className="invitation-card"> <div className="invitation-header"> <div> <h3 className="invitation-title text-lg font-semibold">{inv.projectName}</h3> <p className="text-amber-700 text-sm">{inv.projectDescription}</p> <div className="flex items-center gap-4 text-xs text-amber-600 mt-2"> <span>From {inv.inviterName}</span> <span>{new Date(inv.createdAt).toLocaleDateString()}</span> </div> </div> <div className="invitation-actions"> <button onClick={() => respondToInvitation(inv.id, true)} disabled={loading} className="btn btn-success btn-sm" > <Check className="w-4 h-4" /> Accept </button> <button onClick={() => respondToInvitation(inv.id, false)} disabled={loading} className="btn btn-outline btn-sm" > <X className="w-4 h-4" /> Decline </button> </div> </div> </div> ))} </div> </div> )} {/* Create Project Form */} {showCreateForm && ( <div className="card mb-8"> <div className="card-header"> <div className="flex items-center gap-3"> <div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center"> <Plus className="w-5 h-5 text-white" /> </div> <div> <h2 className="text-xl font-semibold">Create New Project</h2> <p className="text-gray-600 text-sm">Start a new collaboration</p> </div> </div> </div> <form onSubmit={createProject}> <div className="form-group"> <label className="form-label">Project Name</label> <input type="text" value={newProject.name} onChange={(e) => setNewProject({...newProject, name: e.target.value})} className="input" placeholder="Enter project name" required /> </div> <div className="form-group"> <label className="form-label">Description</label> <textarea value={newProject.description} onChange={(e) => setNewProject({...newProject, description: e.target.value})} className="input textarea" placeholder="Describe your project" required /> </div> <div className="flex gap-3"> <button type="submit" disabled={loading} className="btn btn-primary" > {loading ? <div className="spinner" /> : <Plus className="w-4 h-4" />} Create Project </button> <button type="button" onClick={() => setShowCreateForm(false)} className="btn btn-outline" > Cancel </button> </div> </form> </div> )} {/* Projects Grid */} <div className="grid grid-3"> {loading && projects.length === 0 ? ( <div className="col-span-full text-center py-16"> <div className="spinner mx-auto mb-4"></div> <p className="text-gray-600">Loading projects...</p> </div> ) : projects.length === 0 ? ( <div className="col-span-full text-center py-16"> <div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4"> <FileText className="w-8 h-8 text-gray-400" /> </div> <h3 className="text-xl font-semibold text-gray-900 mb-2">No Projects Yet</h3> <p className="text-gray-600 max-w-md mx-auto"> {user?.role === 'superadmin' ? 'Create your first project to start collaborating' : 'You\'ll see projects here when invited to collaborate' } </p> </div> ) : ( projects.map((project) => ( <div key={project.id} className="project-card"> <div className="flex justify-between items-start mb-4"> <div className="project-icon"> <FileText className="w-6 h-6 text-white" /> </div> <div className="flex gap-2"> <button onClick={() => showProjectMembers(project)} className="btn btn-outline btn-sm" title="View members" > <Users className="w-4 h-4" /> </button> {user?.role === 'superadmin' && ( <button onClick={() => openInviteModal(project)} className="btn btn-success btn-sm" title="Invite users" > <Send className="w-4 h-4" /> </button> )} </div> </div> <h3 className="project-title">{project.name}</h3> <p className="project-description">{project.description}</p> <div className="project-meta"> <div className="flex items-center gap-3"> <div className="flex items-center gap-1"> <Users className="w-4 h-4" /> <span>{project.members?.length || 1}</span> </div> {project.hasStagedChanges && ( <span className="badge badge-warning"> Pending </span> )} </div> <span>{new Date(project.createdAt).toLocaleDateString()}</span> </div> <div className="project-actions"> <button onClick={() => openProject(project)} disabled={loading} className="btn btn-primary w-full" > <Edit className="w-4 h-4" /> Open Project </button> </div> </div> )) )} </div> {/* Invite Modal */} {showInviteModal && selectedProject && ( <div className="modal-overlay"> <div className="invite-modal"> <div className="invite-modal-header"> <div className="invite-header-content"> <div className="invite-icon"> <Send className="w-5 h-5 text-white" /> </div> <div className="invite-title"> <h3>Invite Users</h3> <p>to {selectedProject.name}</p> </div> </div> <button onClick={() => setShowInviteModal(false)} className="invite-close-btn" > <X className="w-4 h-4" /> </button> </div> <div className="invite-modal-body"> <div className="invite-search"> <input type="text" placeholder="Search users..." className="invite-search-input" /> </div> <div className="invite-users-list"> {allUsers.filter(u => !selectedProject.members?.includes(u.id)).map((dbUser) => ( <div key={dbUser.id} className="invite-user-item"> <label className="invite-user-label"> <input type="checkbox" checked={selectedUsers.includes(dbUser.id)} onChange={(e) => { if (e.target.checked) { setSelectedUsers([...selectedUsers, dbUser.id]); } else { setSelectedUsers(selectedUsers.filter(id => id !== dbUser.id)); } }} className="invite-checkbox" /> <div className="invite-user-avatar"> {(dbUser.firstName || 'U').charAt(0)}{(dbUser.lastName || 'U').charAt(0)} </div> <div className="invite-user-info"> <div className="invite-user-name">{dbUser.fullName}</div> <div className="invite-user-email">{dbUser.email}</div> </div> {selectedUsers.includes(dbUser.id) && ( <div className="invite-selected-icon"> <Check className="w-4 h-4" /> </div> )} </label> </div> ))} </div> </div> <div className="invite-modal-footer"> <div className="invite-selected-count"> {selectedUsers.length} user{selectedUsers.length !== 1 ? 's' : ''} selected </div> <div className="invite-actions"> <button onClick={() => setShowInviteModal(false)} className="btn btn-outline" > Cancel </button> <button onClick={inviteUsers} disabled={selectedUsers.length === 0 || loading} className="btn btn-primary" > {loading ? <div className="spinner" /> : <Send className="w-4 h-4" />} Send Invitations </button> </div> </div> </div> </div> )} {/* Members Modal */} {showMembersModal && selectedProject && ( <div className="modal-overlay"> <div className="modal" style={{maxWidth: '400px'}}> <div className="modal-header"> <div className="flex items-center gap-3"> <div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center"> <Users className="w-5 h-5 text-white" /> </div> <div> <h3 className="text-lg font-semibold">Project Members</h3> <p className="text-gray-600 text-sm">{selectedProject.name}</p> </div> </div> <button onClick={() => setShowMembersModal(false)} className="btn btn-outline btn-sm !p-2" > <X className="w-4 h-4" /> </button> </div> <div className="modal-body"> <div className="space-y-3"> {selectedProject.memberNames?.map((name, index) => ( <div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"> <div className="w-10 h-10 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-semibold text-sm"> {(name || 'U').charAt(0)} </div> <div className="flex-1"> <p className="font-medium text-gray-900">{name}</p> <div className="flex items-center gap-2 mt-1"> {selectedProject.createdBy === selectedProject.members?.[index] ? ( <span className="badge badge-primary"> Owner </span> ) : ( <span className="badge badge-success"> Member </span> )} <div className="status-online"></div> </div> </div> </div> ))} </div> </div> </div> </div> )} {/* Pending Changes Modal */} {showChangesModal && ( <div className="modal-overlay"> <div className="modal" style={{maxWidth: '800px'}}> <div className="modal-header"> <div className="flex items-center gap-3"> <div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center"> <Clock className="w-5 h-5 text-white" /> </div> <div> <h3 className="text-lg font-semibold">Pending Changes Review</h3> <p className="text-gray-600 text-sm">Review and approve user contributions</p> </div> </div> <button onClick={() => setShowChangesModal(false)} className="btn btn-outline btn-sm !p-2" > <X className="w-4 h-4" /> </button> </div> <div className="modal-body"> {pendingChanges.length === 0 ? ( <div className="text-center py-12"> <div className="w-16 h-16 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4"> <Check className="w-8 h-8 text-green-600" /> </div> <h4 className="text-lg font-semibold mb-2">All Caught Up!</h4> <p className="text-gray-600">No pending changes to review</p> </div> ) : ( <div className="space-y-6"> {pendingChanges.map((change) => ( <div key={change.id} className="border rounded-lg p-4"> <div className="flex justify-between items-start mb-4"> <div className="flex items-center gap-3"> <div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center text-white font-semibold text-sm"> {(change.userName || 'U').charAt(0)} </div> <div> <h4 className="font-semibold">{change.projectName}</h4> <p className="text-gray-600 text-sm">By {change.userName}</p> <p className="text-gray-500 text-xs">{new Date(change.createdAt).toLocaleString()}</p> </div> </div> <div className="flex gap-2"> <button onClick={() => reviewChange(change.id, true)} disabled={loading} className="btn btn-success btn-sm" > <Check className="w-4 h-4" /> Approve </button> <button onClick={() => reviewChange(change.id, false)} disabled={loading} className="btn btn-danger btn-sm" > <X className="w-4 h-4" /> Reject </button> </div> </div> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div> <h5 className="font-medium mb-2 text-sm">Original Content</h5> <div className="bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto"> <pre className="text-gray-800 text-xs whitespace-pre-wrap">{change.originalContent}</pre> </div> </div> <div> <h5 className="font-medium mb-2 text-sm">Proposed Changes</h5> <div className="bg-green-50 border border-green-200 p-3 rounded-lg max-h-32 overflow-y-auto"> <pre className="text-gray-800 text-xs whitespace-pre-wrap">{change.proposedContent}</pre> </div> </div> </div> </div> ))} </div> )} </div> </div> </div> )} </div> </div> ); }; // Real-time Project Editor const ProjectEditor = ({ project, user, onBack }) => { const [content, setContent] = useState(project.content || ''); const [socket, setSocket] = useState(null); const [collaborators, setCollaborators] = useState([]); const [cursors, setCursors] = useState({}); const [saving, setSaving] = useState(false); const [lastSaved, setLastSaved] = useState(new Date()); const [comments, setComments] = useState([]); const [showCommentModal, setShowCommentModal] = useState(false); const [selectedText, setSelectedText] = useState({ text: '', start: 0, end: 0 }); const [newComment, setNewComment] = useState(''); const [editorRef, setEditorRef] = useState(null); const [typingUsers, setTypingUsers] = useState(new Set()); const [wordCount, setWordCount] = useState(0); const [isTyping, setIsTyping] = useState(false); const [typingTimeout, setTypingTimeout] = useState(null); const [projectChanges, setProjectChanges] = useState([]); const [showChangesPanel, setShowChangesPanel] = useState(false); const [showHistory, setShowHistory] = useState(false); const [history, setHistory] = useState([]); const [darkMode, setDarkMode] = useState(false); const [textToSpeech, setTextToSpeech] = useState(false); const [speechToText, setSpeechToText] = useState(false); const [isRecording, setIsRecording] = useState(false); const [recognition, setRecognition] = useState(null); const [focusMode, setFocusMode] = useState(false); const [showMinimap, setShowMinimap] = useState(false); const [aiAssistant, setAiAssistant] = useState(false); const [smartSuggestions, setSmartSuggestions] = useState([]); const [readingMode, setReadingMode] = useState(false); useEffect(() => { const newSocket = io('http://localhost:3000'); setSocket(newSocket); newSocket.emit('join-project', { projectId: project.id, user: { id: user.id, name: user.fullName, color: getRandomColor() } }); newSocket.on('room-users', (users) => { setCollaborators(users); }); newSocket.on('user-joined', ({ user: newUser }) => { setCollaborators(prev => [...prev, newUser]); }); newSocket.on('user-left', ({ socketId }) => { setCollaborators(prev => prev.filter(u => u.socketId !== socketId)); setCursors(prev => { const newCursors = { ...prev }; delete newCursors[socketId]; return newCursors; }); }); newSocket.on('content-update', ({ content: newContent, user: updateUser, cursorPosition }) => { if (updateUser.id !== user.id) { setContent(newContent); } }); newSocket.on('cursor-update', ({ x, y, socketId, user: cursorUser, textPosition }) => { setCursors(prev => ({ ...prev, [socketId]: { x, y, user: cursorUser, textPosition } })); }); newSocket.on('comment-added', (comment) => { setComments(prev => [...prev, comment]); }); newSocket.on('comment-deleted', ({ commentId }) => { setComments(prev => prev.filter(c => c.id !== commentId)); }); newSocket.on('user-typing', ({ socketId, isTyping: userTyping, user: typingUser }) => { setTypingUsers(prev => { const newSet = new Set(prev); if (userTyping) { newSet.add(socketId); } else { newSet.delete(socketId); } return newSet; }); }); newSocket.on('cursor-position', ({ socketId, textPosition, user: cursorUser }) => { setCursors(prev => ({ ...prev, [socketId]: { ...prev[socketId], textPosition, user: cursorUser } })); }); newSocket.on('history-updated', () => { loadHistory(); }); newSocket.on('content-saved', ({ userName, timestamp }) => { // Add to local history immediately const newHistoryEntry = { id: Date.now().toString(), type: 'edit', userName, timestamp, editType: 'direct_edit' }; setHistory(prev => [newHistoryEntry, ...prev.slice(0, 49)]); }); // Load existing comments and project data loadComments(); loadProjectChanges(); loadHistory(); initializeSpeechFeatures(); return () => { newSocket.close(); if (recognition) { recognition.stop(); } }; }, [project.id, user.id, user.fullName]); const loadComments = async () => { try { const token = localStorage.getItem('authToken'); const response = await fetch(`http://localhost:3000/projects/${project.id}/comments`, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const result = await response.json(); if (result.success) { setComments(result.comments || []); } } else { console.warn('Comments endpoint not available, using empty array'); setComments([]); } } catch (error) { console.error('Failed to load comments:', error); setComments([]); } }; const handleContentChange = (e) => { const newContent = e.target.value; const cursorPosition = e.target.selectionStart; // Update content immediately setContent(newContent); // Update word count const words = newContent.trim().split(/\s+/).filter(word => word.length > 0); setWordCount(words.length); // Handle typing indicator if (!isTyping) { setIsTyping(true); if (socket) { socket.emit('user-typing', { isTyping: true }); } } // Clear existing timeout if (typingTimeout) { clearTimeout(typingTimeout); } // Set new timeout to stop typing indicator const timeout = setTimeout(() => { setIsTyping(false); if (socket) { socket.emit('user-typing', { isTyping: false }); } }, 1000); setTypingTimeout(timeout); // Emit content change to other users if (socket) { socket.emit('content-change', { content: newContent, cursorPosition }); } }; const handleMouseMove = (e) => { if (socket && editorRef) { const rect = editorRef.getBoundingClientRect(); const relativeX = Math.max(0, e.clientX - rect.left - 16); const relativeY = Math.max(0, e.clientY - rect.top - 16); const textPosition = getTextPositionFromCoords(relativeX, relativeY); socket.emit('cursor-move', { x: e.clientX, y: e.clientY, relativeX, relativeY, textPosition, editorBounds: { left: rect.left, top: rect.top, width: rect.width, height: rect.height } }); } }; const getTextPositionFromCoords = (x, y) => { if (!editorRef || !content) return 0; try { const lineHeight = 24; const charWidth = 8.5; const line = Math.max(0, Math.floor(y / lineHeight)); const char = Math.max(0, Math.floor(x / charWidth)); const lines = content.split('\n'); if (lines.length === 0) return 0; let position = 0; for (let i = 0; i < line && i < lines.length; i++) { position += (lines[i] || '').length + 1; } if (line < lines.length && lines[line]) { position += Math.min(char, lines[line].length); } return Math.max(0, Math.min(position, content.length)); } catch (error) { console.warn('Error calculating text position:', error); return 0; } }; const handleTextSelection = () => { if (!editorRef) return; const start = editorRef.selectionStart; const end = editorRef.selectionEnd; if (start !== end && start < end) { const selectedText = content.substring(start, end); if (selectedText.trim().length > 0) { setSelectedText({ text: selectedText.trim(), start, end }); console.log('Text selected:', selectedText.trim()); } } else { // Clear selection if no text is selected setSelectedText({ text: '', start: 0, end: 0 }); } }; const addComment = async () => { if (!newComment.trim() || !selectedText.text) { alert('Please enter a comment and select text'); return; } try { const token = localStorage.getItem('authToken'); const response = await fetch(`http://localhost:3000/projects/${project.id}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ text: newComment.trim(), selectedText: selectedText.text.trim(), startPosition: selectedText.start || 0, endPosition: selectedText.end || selectedText.text.length }) }); if (response.ok) { const result = await response.json(); if (result.success && result.comment) { // Add comment to local state immediately setComments(prev => [...prev, result.comment]); // Clear form setNewComment(''); setShowCommentModal(false); setSelectedText({ text: '', start: 0, end: 0 }); // Broadcast to other users if (socket) { socket.emit('comment-added', result.comment); } alert('Comment added successfully!'); } else { alert('Failed to add comment: ' + (result.message || 'Unknown error')); } } else { const errorResult = await response.json(); alert('Failed to add comment: ' + (errorResult.message || 'Server error')); } } catch (error) { console.error('Add comment error:', error); alert('Failed to add comment: ' + error.message); } }; const deleteComment = async (commentId) => { if (!user || (user.role !== 'superadmin' && user.role !== 'admin')) { alert('Only admins can delete comments'); return; } if (!confirm('Are you sure you want to delete this comment?')) { return; } try { const token = localStorage.getItem('authToken'); const response = await fetch(`http://localhost:3000/projects/${project.id}/comments/${commentId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const result = await response.json(); if (result.success) { // Remove comment from local state setComments(prev => prev.filter(c => c.id !== commentId)); // Broadcast to other users if (socket) { socket.emit('comment-deleted', { commentId }); } alert('Comment deleted successfully!'); } } else { const errorResult = await response.json(); alert('Failed to delete comment: ' + (errorResult.message || 'Server error')); } } catch (error) { console.error('Delete comment error:', error); alert('Failed to delete comment: ' + error.message); } }; const loadProjectChanges = async () => { if (user?.role === 'superadmin') { try { const token = localStorage.getItem('authToken'); const response = await fetch(`http://localhost:3000/projects/${project.id}/staged-changes`, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { const result = await response.json(); if (result.success) { setProjectChanges(result.changes || []); } } else { setProjectChanges([]); } } catch (error) { console.error('Failed to load project changes:', error); setProjectChanges([]); } } }; const approveChangeWithFeedback = async (changeId, feedback = null) => { try { const token = localStorage.getItem('authToken'); const response = await fetch(`http://localhost:3000/staged-changes/${changeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ approve: true, feedback }) }); const result = await response.json(); if (result.success) { setProjectChanges(prev => prev.filter(change => change.id !== changeId)); alert('Changes approved and merged successfully!'); // Reload content and history const projectResponse = await fetch(`http://localhost:3000/projects/${project.id}`, { headers: { 'Authorization': `Bearer ${token}` } }); const projectResult = await projectResponse.json(); if (projectResult.success) { setContent(projectResult.project.content); } loadHistory(); // Refresh history to show the merge } } catch (error) { alert('Failed to approve change: ' + error.message); } }; const rejectChangeWithFeedback = async (changeId, feedback) => { try { const token = localStorage.getItem('authToken'); const response = await fetch(`http://localhost:3000/staged-changes/${changeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ approve: false, feedback }) }); const result = await response.json(); if (result.success) { setProjectChanges(prev => prev.filter(change => change.id !== changeId)); alert('Changes rejected with feedback!'); loadHistory(); // Refresh history to show the rejection } } catch (error) { alert('Failed to reject change: ' + error.message); } }; // Legacy functions for backward compatibility const approveChange = (changeId) => approveChangeWithFeedback(changeId); const rejectChange = (changeId) => { const feedback = prompt('Reason for rejection:'); if (feedback) rejectChangeWithFeedback(changeId, feedback); }; // Load project history const loadHistory = async () => { try { const token = localStorage.getItem('authToken'); const historyRes = await fetch(`http://localhost:3000/projects/${project.id}/history`, { headers: { 'Authorization': `Bearer ${token}` } }); if (historyRes.ok) { const historyResult = await historyRes.json(); if (historyResult.success) { setHistory(historyResult.history || []); } } } catch (error) { console.error('Failed to load history:', error); } }; // Advanced Features const initializeSpeechFeatures = () => { if ('speechSynthesis' in window && 'webkitSpeechRecognition' in window) { const SpeechRecognition = window.webkitSpeechRecognition; const recognitionInstance = new SpeechRecognition(); recognitionInstance.continuous = true; recognitionInstance.interimResults = true; recognitionInstance.lang = 'en-US'; setRecognition(recognitionInstance); } }; const speakText = (text) => { if ('speechSynthesis' in window && text.trim()) { window.speechSynthesis.cancel(); // Wait for voices to load const speak = () => { const utterance = new SpeechSynthesisUtterance(text); utterance.rate = 0.8; utterance.pitch = 1; utterance.volume = 0.9; const voices = window.speechSynthesis.getVoices(); const preferredVoice = voices.find(voice => voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Microsoft')) ) || voices.find(voice => voice.lang.startsWith('en')) || voices[0]; if (preferredVoice) { utterance.voice = preferredVoice; } utterance.onstart = () => console.log('Speech started'); utterance.onend = () => console.log('Speech ended'); utterance.onerror = (e) => console.error('Speech error:', e); window.speechSynthesis.speak(utterance); }; // Ensure voices are loaded if (window.speechSynthesis.getVoices().length === 0) { window.speechSynthesis.onvoiceschanged = () => { speak(); window.speechSynthesis.onvoiceschanged = null; }; } else { speak(); } } }; const startSpeechToText = () => { if (!recognition) { alert('❌ Speech recognition not supported in this browser'); return; } if (!isRecording) { try { setIsRecording(true); recognition.start(); recognition.onresult = (event) => { let transcript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { transcript += event.results[i][0].transcript; } if (event.results[event.results.length - 1].isFinal) { const cursorPos = editorRef?.selectionStart || content.length; const newContent = content.slice(0, cursorPos) + ' ' + transcript + content.slice(cursorPos); setContent(newContent); if (socket) { socket.emit('content-change', { content: newContent }); } } }; recognition.onerror = (event) => { console.error('Speech recognition error:', event.error); setIsRecording(false); if (event.error !== 'aborted') { alert('❌ Speech recognition error: ' + event.error); } }; recognition.onend = () => { setIsRecording(false); }; } catch (error) { console.error('Failed to start speech recognition:', error); setIsRecording(false); alert('❌ Failed to start speech recognition'); } } else { recognition.stop(); setIsRecording(false); } }; const generateSmartSuggestions = async () => { if (!content.trim()) { setSmartSuggestions([]); return; } const suggestions = []; const text = content.toLowerCase(); const lines = content.split('\n'); // Advanced Grammar Analysis const grammarIssues = [ { pattern: /\bthere is (multiple|many|several|\d+)/gi, suggestion: 'Use "there are" with plural subjects', fix: (text) => text.replace(/there is/gi, 'there are'), type: 'grammar' }, { pattern: /\b(could of|would of|should of)\b/gi, suggestion: 'Use "could have", "would have", "should have" instead of "of"', fix: (text) => text.replace(/ of\b/gi, ' have'), type: 'grammar' }, { pattern: /\bi\s+/gi, suggestion: 'Capitalize the pronoun "I"', fix: (text) => text.replace(/\bi\b/g, 'I'), type: 'capitalization' } ]; // Check each grammar rule grammarIssues.forEach(rule => { const matches = content.match(rule.pattern); if (matches) { suggestions.push({ type: rule.type, text: rule.suggestion, icon: rule.type === 'grammar' ? '📝' : '🔤', fix: rule.fix ? rule.fix(content) : null, severity: 'high' }); } }); // Style Analysis const avgWordsPerSentence = content.split(/[.!?]+/).filter(s => s.trim()).reduce((acc, sentence) => { return acc + sentence.trim().split(/\s+/).length; }, 0) / Math.max(1, content.split(/[.!?]+/).length - 1); if (avgWordsPerSentence > 25) { suggestions.push({ type: 'style', text: 'Consider shorter sentences for better readability (avg: ' + Math.round(avgWordsPerSentence) + ' words)', icon: '✂️', severity: 'medium' }); } // Readability improvements const wordCount = content.trim().split(/\s+/).length; if (wordCount > 100 && !content.includes('\n\n')) { suggestions.push({ type: 'readability', text: 'Add paragraph breaks to improve document structure', icon: '📄', severity: 'low' }); } // Passive voice detection const passiveMatches = content.match(/\b(was|were|been|being)\s+\w*ed\b/gi); if (passiveMatches && passiveMatches.length > 2) { suggestions.push({ type: 'style', text: `Found ${passiveMatches.length} instances of passive voice. Consider active voice for clarity`, icon: '🎯', severity: 'medium' }); } // AI-powered suggestions (simulated) if (content.includes('however') && content.includes('but')) { suggestions.push({ type: 'style', text: 'Avoid using both "however" and "but" - choose one for consistency', icon: '🔄', severity: 'low' }); } setSmartSuggestions(suggestions.slice(0, 5)); // Limit to 5 suggestions }; const handleEnhancedTextSelection = () => { handleTextSelection(); if (textToSpeech && editorRef) { const start = editorRef.selectionStart; const end = editorRef.selectionEnd; if (start !== end && start < end) { const selectedText = content.substring(start, end); if (selectedText.trim().length > 0) { speakText(selectedText.trim()); } } } }; const saveContent = async (silent = false) => { if (saving) return; // Prevent multiple saves try { setSaving(true); const token = localStorage.getItem('authToken'); // Add delay to prevent instant action await new Promise(resolve => setTimeout(resolve, 500)); const response = await fetch(`http://localhost:3000/projects/${project.id}/content`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ content: content || '' }) }); if (response.ok) { const result = await response.json(); if (result.success) { setLastSaved(new Date()); if (socket) { socket.emit('content-saved', { userName: user.fullName }); socket.emit('history-update'); } loadHistory(); if (!silent) { if (result.staged) { alert('✅ Changes submitted for superadmin approval!'); } else { alert('✅ Content saved successfully!'); } } } else { if (!silent) alert('❌ ' + (result.message || 'Failed to save content')); } } else { const errorResult = await response.json(); if (!silent) alert('❌ Failed to save: ' + (errorResult.message || 'Server error')); } } catch (error) { if (!silent) alert('❌ Failed to save content: ' + error.message); } finally { setSaving(false); } }; return ( <div className="page-container" onMouseMove