UNPKG

sourabhrealtime

Version:

ROBUST RICH TEXT EDITOR: Single-pane contentEditable with direct text selection formatting, speech features, undo/redo, professional UI - Perfect TipTap alternative

656 lines (577 loc) 24.3 kB
import React, { useState, useEffect, useRef, useCallback } from 'react'; import { supabaseAPI } from './supabase-api.js'; import { ProjectManager } from './project-manager.js'; import { RealtimeEngine } from './realtime-engine.js'; // Initialize managers const projectManager = new ProjectManager(); const realtimeEngine = new RealtimeEngine(); export const UserRole = { SUPER_ADMIN: 'super_admin', ADMIN: 'admin', USER: 'user' }; const SaaSCollaboration = ({ apiUrl = 'http://localhost:3002' }) => { // Authentication state const [currentUser, setCurrentUser] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); const [authToken, setAuthToken] = useState(null); // UI state const [showLogin, setShowLogin] = useState(true); const [showCreateProject, setShowCreateProject] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false); const [loading, setLoading] = useState(false); // Project state const [projects, setProjects] = useState([]); const [currentProject, setCurrentProject] = useState(null); const [projectContent, setProjectContent] = useState(''); const [projectMembers, setProjectMembers] = useState([]); // Collaboration state const [collaborators, setCollaborators] = useState([]); const [cursors, setCursors] = useState([]); const [typingUsers, setTypingUsers] = useState([]); const [connected, setConnected] = useState(false); // Admin state const [allUsers, setAllUsers] = useState([]); const [invitations, setInvitations] = useState([]); const [notifications, setNotifications] = useState([]); // Forms const [loginForm, setLoginForm] = useState({ email: '', password: '' }); const [projectForm, setProjectForm] = useState({ name: '', description: '' }); const [selectedUsersToInvite, setSelectedUsersToInvite] = useState([]); const socketRef = useRef(null); const editorRef = useRef(null); const isLocalChange = useRef(false); // Persist session useEffect(() => { const savedUser = localStorage.getItem('saas_collab_user'); const savedToken = localStorage.getItem('saas_collab_token'); if (savedUser && savedToken) { setCurrentUser(JSON.parse(savedUser)); setAuthToken(savedToken); setIsAuthenticated(true); setShowLogin(false); loadUserData(JSON.parse(savedUser)); } }, []); const addNotification = useCallback((message, type = 'info') => { const notification = { id: Date.now(), message, type }; setNotifications(prev => [...prev, notification]); setTimeout(() => { setNotifications(prev => prev.filter(n => n.id !== notification.id)); }, 4000); }, []); const handleLogin = useCallback(async (e) => { e.preventDefault(); setLoading(true); try { const result = await supabaseAPI.authenticateUser(loginForm.email, loginForm.password); if (result.success) { setCurrentUser(result.user); setAuthToken(result.token); setIsAuthenticated(true); setShowLogin(false); // Persist session localStorage.setItem('saas_collab_user', JSON.stringify(result.user)); localStorage.setItem('saas_collab_token', result.token); addNotification(`Welcome back, ${result.user.name}! 🎉`, 'success'); loadUserData(result.user); connectSocket(result.user); } else { addNotification(result.message || 'Login failed', 'error'); } } catch (error) { addNotification('Login failed - check connection', 'error'); } finally { setLoading(false); } }, [loginForm, addNotification]); const loadUserData = useCallback(async (user) => { // Load user's projects const userProjects = projectManager.getUserProjects(user.id); setProjects(userProjects); // Load user's invitations const userInvitations = projectManager.getUserInvitations(user.id); setInvitations(userInvitations); // Load all users if super admin if (user.role === UserRole.SUPER_ADMIN) { const users = await supabaseAPI.getAllUsers(); setAllUsers(users); } }, []); const connectSocket = useCallback((user) => { if (socketRef.current) return; try { const io = require('socket.io-client'); const socket = io(apiUrl, { auth: { token: authToken, user } }); socketRef.current = socket; socket.on('connect', () => { setConnected(true); addNotification('Connected to collaboration server', 'success'); }); socket.on('disconnect', () => { setConnected(false); addNotification('Disconnected from server', 'warning'); }); // Real-time collaboration events socket.on('user-joined', (data) => { setCollaborators(prev => [...prev.filter(u => u.id !== data.user.id), data.user]); addNotification(`${data.user.name} joined the project`, 'info'); }); socket.on('user-left', (data) => { setCollaborators(prev => prev.filter(u => u.id !== data.userId)); setCursors(prev => prev.filter(c => c.userId !== data.userId)); setTypingUsers(prev => prev.filter(t => t.userId !== data.userId)); }); socket.on('content-update', (data) => { if (!isLocalChange.current && data.userId !== user.id) { setProjectContent(data.content); if (editorRef.current) { editorRef.current.innerHTML = data.content; } } }); socket.on('cursor-update', (data) => { if (data.userId !== user.id) { setCursors(prev => [ ...prev.filter(c => c.userId !== data.userId), { ...data, color: realtimeEngine.generateUserColor(data.userId) } ]); } }); socket.on('typing-update', (data) => { if (data.userId !== user.id) { setTypingUsers(prev => { if (data.isTyping) { return [...prev.filter(t => t.userId !== data.userId), data]; } else { return prev.filter(t => t.userId !== data.userId); } }); } }); socket.on('invitation-received', (invitation) => { setInvitations(prev => [...prev, invitation]); addNotification(`New project invitation: "${invitation.projectName}"`, 'info'); }); } catch (error) { addNotification('Failed to connect to server', 'error'); } }, [apiUrl, authToken, addNotification]); const createProject = useCallback(async (e) => { e.preventDefault(); const project = projectManager.createProject(projectForm, currentUser.id); setProjects(prev => [...prev, project]); setShowCreateProject(false); setProjectForm({ name: '', description: '' }); addNotification(`Project "${project.name}" created successfully! 🚀`, 'success'); }, [currentUser, projectForm, addNotification]); const joinProject = useCallback((projectId) => { const project = projects.find(p => p.id === projectId); if (!project) return; setCurrentProject(project); setProjectContent(project.content); // Load project members const members = projectManager.getProjectMembers(projectId); setProjectMembers(members); // Join socket room if (socketRef.current) { socketRef.current.emit('join-project', { projectId, user: currentUser }); } addNotification(`Joined "${project.name}" - Start collaborating! ✨`, 'success'); }, [projects, currentUser, addNotification]); const handleContentChange = useCallback((e) => { const newContent = e.target.innerHTML; isLocalChange.current = true; setProjectContent(newContent); if (socketRef.current && currentProject) { // Update project content projectManager.updateContent(currentProject.id, currentUser.id, newContent, 'Content edited'); // Emit to other users socketRef.current.emit('content-update', { projectId: currentProject.id, content: newContent, userId: currentUser.id, timestamp: Date.now() }); } setTimeout(() => { isLocalChange.current = false; }, 100); }, [currentProject, currentUser]); const inviteUsersToProject = useCallback(() => { if (!currentProject || selectedUsersToInvite.length === 0) return; selectedUsersToInvite.forEach(userId => { const invitation = projectManager.sendInvitation( currentProject.id, userId, currentUser.id, 'editor' ); // Notify user via socket if (socketRef.current) { socketRef.current.emit('send-invitation', invitation); } }); setSelectedUsersToInvite([]); setShowInviteModal(false); addNotification(`Invitations sent to ${selectedUsersToInvite.length} users! 📧`, 'success'); }, [currentProject, selectedUsersToInvite, currentUser, addNotification]); const acceptInvitation = useCallback((invitationId) => { const result = projectManager.acceptInvitation(invitationId, currentUser.id); if (result.success) { setProjects(prev => [...prev, result.project]); setInvitations(prev => prev.filter(inv => inv.id !== invitationId)); addNotification(`Joined "${result.project.name}" successfully! 🎉`, 'success'); } else { addNotification(result.message, 'error'); } }, [currentUser, addNotification]); const formatText = useCallback((command, value = null) => { document.execCommand(command, false, value); if (editorRef.current) { handleContentChange({ target: editorRef.current }); } }, [handleContentChange]); const logout = useCallback(() => { localStorage.removeItem('saas_collab_user'); localStorage.removeItem('saas_collab_token'); if (socketRef.current) socketRef.current.disconnect(); setCurrentUser(null); setIsAuthenticated(false); setAuthToken(null); setShowLogin(true); setProjects([]); setCurrentProject(null); setCollaborators([]); addNotification('Logged out successfully', 'info'); }, [addNotification]); const isSuperAdmin = currentUser?.role === UserRole.SUPER_ADMIN; if (showLogin) { return ( <div className="auth-container"> <div className="auth-card"> <h1 className="auth-title">🚀 SaaS Collaboration Platform</h1> <form onSubmit={handleLogin}> <div className="form-group"> <label className="form-label">Email Address</label> <input type="email" className="form-input" placeholder="Enter your email" value={loginForm.email} onChange={(e) => setLoginForm(prev => ({ ...prev, email: e.target.value }))} required /> </div> <div className="form-group"> <label className="form-label">Password</label> <input type="password" className="form-input" placeholder="Enter your password" value={loginForm.password} onChange={(e) => setLoginForm(prev => ({ ...prev, password: e.target.value }))} required /> </div> <button type="submit" className="btn btn-primary btn-lg" disabled={loading} style={{ width: '100%', marginTop: '20px' }}> {loading ? '⏳ Signing In...' : '🚀 Sign In'} </button> </form> <div style={{ marginTop: '24px', padding: '20px', background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)', borderRadius: '12px', fontSize: '14px', color: '#64748b' }}> <p style={{ fontWeight: '600', marginBottom: '8px' }}>🎯 SaaS Platform</p> <p>Use your Supabase credentials to login</p> <p>Super admins can manage all users and projects</p> </div> </div> </div> ); } return ( <div className="saas-platform" style={{ minHeight: '100vh', background: '#f1f5f9' }}> {/* Notifications */} <div className="notifications"> {notifications.map(notification => ( <div key={notification.id} className={`notification ${notification.type}`}> {notification.message} </div> ))} </div> {/* Invitation Banner */} {invitations.length > 0 && ( <div className="invitation-banner"> <div className="invitation-content"> <h3>📨 You have {invitations.length} project invitation(s)</h3> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '12px' }}> {invitations.slice(0, 3).map(invitation => ( <div key={invitation.id} className="invitation-item"> <div> <strong>{invitation.projectName}</strong> <div style={{ fontSize: '0.9rem', opacity: 0.9 }}> Role: {invitation.role} </div> </div> <div className="invitation-actions"> <button onClick={() => acceptInvitation(invitation.id)} className="btn btn-success btn-sm"> ✅ Accept </button> </div> </div> ))} </div> </div> </div> )} {/* Header */} <header className="header"> <div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}> <h1 className="header-title">🚀 SaaS Collaboration</h1> <div className="status-indicator"> <div className="status-dot"></div> {connected ? 'Connected' : 'Disconnected'} </div> </div> <div className="header-actions"> <div className="user-info"> <div className="user-name">{currentUser?.name}</div> <div className="user-role">{currentUser?.role}</div> </div> <button onClick={() => setShowCreateProject(true)} className="btn btn-success"> ➕ New Project </button> {currentProject && ( <button onClick={() => setShowInviteModal(true)} className="btn btn-primary"> 👥 Invite Users </button> )} <button onClick={logout} className="btn btn-danger"> 🚪 Logout </button> </div> </header> {/* Main Content */} <div className="main-container"> {/* Sidebar */} <div className="sidebar"> <h3 className="sidebar-title">📁 My Projects</h3> {projects.length === 0 ? ( <div className="empty-state"> <h3>No projects yet</h3> <p>Create your first project to get started!</p> </div> ) : ( <div className="project-list"> {projects.map(project => ( <div key={project.id} className={`project-card ${currentProject?.id === project.id ? 'active' : ''}`} onClick={() => joinProject(project.id)} > <div className="project-name"> 🔓 {project.name} </div> <div className="project-description">{project.description}</div> <div className="project-meta"> <span>👥 {project.memberCount} members</span> <span>{new Date(project.createdAt).toLocaleDateString()}</span> </div> <div style={{ fontSize: '0.8rem', color: '#6b7280', marginTop: '4px' }}> Role: {project.userRole} </div> </div> ))} </div> )} </div> {/* Editor */} <div className="editor-container"> {currentProject ? ( <> <div className="editor-header"> <h3 className="editor-title">✨ {currentProject.name}</h3> <div className="editor-meta"> <div className="collaborator-count"> 👥 {collaborators.length} online </div> <div>Version: {currentProject.version}</div> </div> </div> {/* Enhanced Toolbar */} <div className="editor-toolbar"> <button onClick={() => formatText('bold')} className="toolbar-btn" title="Bold"> <strong>B</strong> </button> <button onClick={() => formatText('italic')} className="toolbar-btn" title="Italic"> <em>I</em> </button> <button onClick={() => formatText('underline')} className="toolbar-btn" title="Underline"> <u>U</u> </button> <div className="toolbar-separator"></div> <button onClick={() => formatText('formatBlock', 'h1')} className="toolbar-btn" title="Heading 1"> H1 </button> <button onClick={() => formatText('formatBlock', 'h2')} className="toolbar-btn" title="Heading 2"> H2 </button> <div className="toolbar-separator"></div> <button onClick={() => formatText('insertUnorderedList')} className="toolbar-btn" title="Bullet List"> • List </button> <button onClick={() => formatText('insertOrderedList')} className="toolbar-btn" title="Numbered List"> 1. List </button> </div> {/* Editor Content */} <div ref={editorRef} className="editor-content" contentEditable onInput={handleContentChange} dangerouslySetInnerHTML={{ __html: projectContent }} style={{ position: 'relative' }} /> {/* Typing Indicators */} {typingUsers.length > 0 && ( <div style={{ padding: '8px 32px', fontSize: '0.9rem', color: '#6b7280', fontStyle: 'italic' }}> {typingUsers.map(user => user.user.name).join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing... </div> )} {/* Collaborators Bar */} <div className="collaborators-bar"> <span className="collaborators-label">👥 Collaborators:</span> {collaborators.map(collaborator => ( <div key={collaborator.id} className="collaborator-avatar" style={{ background: realtimeEngine.generateUserColor(collaborator.id) }} > {collaborator.name.charAt(0)} <div className="collaborator-tooltip"> {collaborator.name} </div> </div> ))} {collaborators.length === 0 && ( <span className="empty-collaborators">No other collaborators online</span> )} </div> </> ) : ( <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#6b7280', textAlign: 'center', padding: '40px' }}> <div style={{ fontSize: '4rem', marginBottom: '24px' }}>🚀</div> <h3 style={{ fontSize: '1.5rem', marginBottom: '16px', color: '#374151' }}> Select a project to start collaborating </h3> <p style={{ fontSize: '1.1rem', marginBottom: '24px' }}> Choose a project from the sidebar or create a new one </p> </div> )} </div> </div> {/* Create Project Modal */} {showCreateProject && ( <div className="modal-overlay"> <div className="modal"> <h2 className="modal-title">🚀 Create New Project</h2> <form onSubmit={createProject}> <div className="form-group"> <label className="form-label">Project Name</label> <input type="text" className="form-input" placeholder="Enter project name" value={projectForm.name} onChange={(e) => setProjectForm(prev => ({ ...prev, name: e.target.value }))} required /> </div> <div className="form-group"> <label className="form-label">Description</label> <textarea className="form-input" placeholder="Describe your project..." value={projectForm.description} onChange={(e) => setProjectForm(prev => ({ ...prev, description: e.target.value }))} style={{ minHeight: '100px', resize: 'vertical' }} /> </div> <div className="modal-actions"> <button type="button" onClick={() => setShowCreateProject(false)} className="btn btn-secondary"> Cancel </button> <button type="submit" className="btn btn-primary"> 🚀 Create Project </button> </div> </form> </div> </div> )} {/* Invite Users Modal */} {showInviteModal && isSuperAdmin && ( <div className="modal-overlay"> <div className="modal"> <h2 className="modal-title">👥 Invite Users to {currentProject?.name}</h2> <div style={{ marginBottom: '20px' }}> <label className="form-label">Select Users to Invite</label> <div style={{ maxHeight: '300px', overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '10px' }}> {allUsers.map(user => ( <div key={user.id} style={{ display: 'flex', alignItems: 'center', padding: '8px', borderRadius: '6px', marginBottom: '4px', background: selectedUsersToInvite.includes(user.id) ? '#eff6ff' : 'transparent' }}> <input type="checkbox" checked={selectedUsersToInvite.includes(user.id)} onChange={(e) => { if (e.target.checked) { setSelectedUsersToInvite(prev => [...prev, user.id]); } else { setSelectedUsersToInvite(prev => prev.filter(id => id !== user.id)); } }} style={{ marginRight: '10px' }} /> <div> <div style={{ fontWeight: '500' }}>{user.name}</div> <div style={{ fontSize: '0.9rem', color: '#6b7280' }}>{user.email}</div> </div> </div> ))} </div> </div> <div className="modal-actions"> <button type="button" onClick={() => setShowInviteModal(false)} className="btn btn-secondary"> Cancel </button> <button onClick={inviteUsersToProject} className="btn btn-primary" disabled={selectedUsersToInvite.length === 0}> 📧 Send Invitations ({selectedUsersToInvite.length}) </button> </div> </div> </div> )} </div> ); }; export default SaaSCollaboration;