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
JavaScript
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;