UNPKG

sourabhrealtime

Version:

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

974 lines (862 loc) 29.6 kB
import React, { useState, useEffect, useRef, useCallback } from 'react'; import io from 'socket.io-client'; // Minimal CSS styles const styles = ` .saas-platform { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; background: #f8fafc; } .auth-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; } .auth-card { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); width: 100%; max-width: 400px; } .form-input { width: 100%; padding: 12px; border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 16px; font-size: 16px; } .btn { padding: 12px 24px; border: none; border-radius: 6px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s; } .btn-primary { background: #3b82f6; color: white; } .btn-success { background: #10b981; color: white; } .btn-danger { background: #ef4444; color: white; } .btn-secondary { background: #6b7280; color: white; } .btn:hover { opacity: 0.9; } .btn:disabled { opacity: 0.6; cursor: not-allowed; } .header { background: white; padding: 16px 24px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; } .main-container { display: flex; gap: 24px; padding: 24px; min-height: calc(100vh - 80px); } .sidebar { width: 300px; background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); height: fit-content; } .project-card { padding: 16px; border: 1px solid #e2e8f0; border-radius: 6px; cursor: pointer; margin-bottom: 12px; transition: all 0.2s; } .project-card:hover { border-color: #3b82f6; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .project-card.active { border-color: #3b82f6; background: #eff6ff; } .editor-container { flex: 1; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: flex; flex-direction: column; } .editor-header { padding: 16px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; } .editor-toolbar { padding: 12px 16px; border-bottom: 1px solid #e2e8f0; display: flex; gap: 8px; background: #f8fafc; } .toolbar-btn { padding: 6px 12px; border: 1px solid #d1d5db; background: white; border-radius: 4px; cursor: pointer; font-size: 14px; transition: all 0.2s; } .toolbar-btn:hover { border-color: #3b82f6; background: #eff6ff; } .toolbar-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; } .rich-editor { flex: 1; padding: 20px; min-height: 400px; outline: none; font-size: 16px; line-height: 1.6; border: none; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; } .modal { background: white; padding: 24px; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); } .user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin-top: 16px; } .user-card { display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; } .notification { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 6px; color: white; font-weight: 500; z-index: 1001; max-width: 300px; } .notification.success { background: #10b981; } .notification.error { background: #ef4444; } .notification.info { background: #3b82f6; } .status-indicator { display: flex; align-items: center; gap: 8px; padding: 4px 12px; border-radius: 12px; font-size: 14px; font-weight: 500; } .status-connected { background: rgba(16, 185, 129, 0.1); color: #10b981; } .status-disconnected { background: rgba(239, 68, 68, 0.1); color: #ef4444; } .status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } .collaborators-bar { display: flex; align-items: center; gap: 12px; padding: 16px 24px; border-top: 1px solid #e2e8f0; background: #f8fafc; } .collaborator-avatar { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 14px; } .typing-indicator { padding: 8px 24px; font-size: 14px; color: #6b7280; font-style: italic; background: #f9fafb; border-top: 1px solid #e5e7eb; } `; // Inject styles if (typeof document !== 'undefined' && !document.getElementById('saas-styles')) { const style = document.createElement('style'); style.id = 'saas-styles'; style.textContent = styles; document.head.appendChild(style); } // Supabase API const SUPABASE_URL = "https://supabase.merai.app"; const SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"; const supabaseAPI = { async authenticateUser(email, password) { try { const response = await fetch(`${SUPABASE_URL}/rest/v1/users?email=eq.${encodeURIComponent(email)}&select=*`, { headers: { 'apikey': SUPABASE_KEY, 'Authorization': `Bearer ${SUPABASE_KEY}`, 'Content-Type': 'application/json' } }); if (response.ok) { const users = await response.json(); if (users.length > 0) { const user = users[0]; return { success: true, user: { id: user.id, email: user.email, name: user.name, role: user.role || 'user', color: user.color || '#3b82f6' } }; } } return { success: false, message: 'Invalid credentials' }; } catch (error) { return { success: false, message: 'Authentication failed' }; } } }; // Rich Text Editor Component const RichEditor = ({ content, onChange, currentUser, projectId, socket, collaborators = [], typingUsers = [] }) => { const editorRef = useRef(null); const [isTyping, setIsTyping] = useState(false); const typingTimeoutRef = useRef(null); const handleContentChange = useCallback((e) => { const newContent = e.target.innerHTML; if (onChange) { onChange(newContent); } // Typing indicators if (!isTyping) { setIsTyping(true); if (socket && projectId && currentUser) { socket.emit('typing-start', { projectId, user: currentUser }); } } if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } typingTimeoutRef.current = setTimeout(() => { setIsTyping(false); if (socket && projectId && currentUser) { socket.emit('typing-stop', { projectId, user: currentUser }); } }, 1000); }, [isTyping, onChange, socket, projectId, currentUser]); const formatText = useCallback((command) => { try { switch (command) { case 'bold': document.execCommand('bold', false, null); break; case 'italic': document.execCommand('italic', false, null); break; case 'underline': document.execCommand('underline', false, null); break; case 'h1': document.execCommand('formatBlock', false, 'h1'); break; case 'h2': document.execCommand('formatBlock', false, 'h2'); break; case 'ul': document.execCommand('insertUnorderedList', false, null); break; case 'ol': document.execCommand('insertOrderedList', false, null); break; } } catch (error) { console.error('Formatting error:', error); } }, []); useEffect(() => { if (editorRef.current && content !== undefined) { const initialContent = content || '<p>Start typing your content here...</p>'; if (editorRef.current.innerHTML !== initialContent) { editorRef.current.innerHTML = initialContent; } } }, [content]); useEffect(() => { return () => { if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } }; }, []); return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%' } }, // Toolbar React.createElement('div', { className: 'editor-toolbar' }, React.createElement('button', { onClick: () => formatText('bold'), className: 'toolbar-btn', title: 'Bold' }, React.createElement('strong', null, 'B')), React.createElement('button', { onClick: () => formatText('italic'), className: 'toolbar-btn', title: 'Italic' }, React.createElement('em', null, 'I')), React.createElement('button', { onClick: () => formatText('underline'), className: 'toolbar-btn', title: 'Underline' }, React.createElement('u', null, 'U')), React.createElement('button', { onClick: () => formatText('h1'), className: 'toolbar-btn', title: 'Heading 1' }, 'H1'), React.createElement('button', { onClick: () => formatText('h2'), className: 'toolbar-btn', title: 'Heading 2' }, 'H2'), React.createElement('button', { onClick: () => formatText('ul'), className: 'toolbar-btn', title: 'Bullet List' }, '• List'), React.createElement('button', { onClick: () => formatText('ol'), className: 'toolbar-btn', title: 'Numbered List' }, '1. List') ), // Editor React.createElement('div', { ref: editorRef, className: 'rich-editor', contentEditable: true, suppressContentEditableWarning: true, onInput: handleContentChange }), // Typing indicator typingUsers.length > 0 && React.createElement('div', { className: 'typing-indicator' }, `${typingUsers.map(t => t.user.name).join(', ')} ${typingUsers.length === 1 ? 'is' : 'are'} typing...` ) ); }; // Main SaaS Collaboration Component const SaaSCollaboration = ({ apiUrl = 'http://localhost:3002' }) => { // State const [currentUser, setCurrentUser] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); const [showLogin, setShowLogin] = useState(true); const [loading, setLoading] = useState(false); const [connected, setConnected] = useState(false); // Project state const [projects, setProjects] = useState([]); const [currentProject, setCurrentProject] = useState(null); const [projectContent, setProjectContent] = useState(''); // UI state const [showCreateProject, setShowCreateProject] = useState(false); const [showAdminPanel, setShowAdminPanel] = useState(false); // Data const [allUsers, setAllUsers] = useState([]); const [notifications, setNotifications] = useState([]); const [collaborators, setCollaborators] = useState([]); const [typingUsers, setTypingUsers] = useState([]); // Forms const [loginForm, setLoginForm] = useState({ email: '', password: '' }); const [projectForm, setProjectForm] = useState({ name: '', description: '' }); const [selectedUsers, setSelectedUsers] = useState([]); const socketRef = useRef(null); // Load session useEffect(() => { const savedUser = localStorage.getItem('saas_user'); if (savedUser) { try { const user = JSON.parse(savedUser); setCurrentUser(user); setIsAuthenticated(true); setShowLogin(false); loadUserData(user); connectSocket(user); } catch (error) { localStorage.removeItem('saas_user'); } } }, []); 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); setIsAuthenticated(true); setShowLogin(false); localStorage.setItem('saas_user', JSON.stringify(result.user)); addNotification(`Welcome ${result.user.name}!`, 'success'); loadUserData(result.user); connectSocket(result.user); } else { addNotification(result.message, 'error'); } } catch (error) { addNotification('Login failed', 'error'); } finally { setLoading(false); } }, [loginForm, addNotification]); const loadUserData = useCallback(async (user) => { try { // Load projects const projectsRes = await fetch(`${apiUrl}/api/projects?userId=${user.id}&userRole=${user.role}`); if (projectsRes.ok) { const data = await projectsRes.json(); setProjects(data.projects || []); } } catch (error) { console.error('Error loading user data:', error); } }, [apiUrl]); const connectSocket = useCallback((user) => { if (socketRef.current) return; try { const socket = io(apiUrl); socketRef.current = socket; socket.on('connect', () => { setConnected(true); addNotification('Connected to server', 'success'); socket.emit('register-user', { userId: user.id, userInfo: user }); }); socket.on('disconnect', () => { setConnected(false); addNotification('Disconnected from server', 'error'); }); socket.on('all-users', (users) => { setAllUsers(users); }); socket.on('project-created', (data) => { if (data.success) { setProjects(prev => [data.project, ...prev]); addNotification(`Project "${data.project.name}" created!`, 'success'); } }); socket.on('project-joined', (data) => { setCurrentProject(data.project); setProjectContent(data.project.content); setCollaborators(data.roomUsers || []); }); socket.on('user-joined-project', (data) => { setCollaborators(prev => [...prev.filter(u => u.id !== data.user.id), data.user]); addNotification(`${data.user.name} joined`, 'info'); }); socket.on('user-left-project', (data) => { setCollaborators(prev => prev.filter(u => u.id !== data.userId)); }); socket.on('content-updated', (data) => { setProjectContent(data.content); }); socket.on('user-typing', (data) => { if (data.isTyping) { setTypingUsers(prev => [...prev.filter(u => u.user.id !== data.user.id), data]); } else { setTypingUsers(prev => prev.filter(u => u.user.id !== data.user.id)); } }); socket.on('error', (data) => { addNotification(data.message, 'error'); }); } catch (error) { addNotification('Failed to connect', 'error'); } }, [apiUrl, addNotification]); const createProject = useCallback(async (e) => { e.preventDefault(); if (currentUser.role !== 'super_admin') { addNotification('Only super admins can create projects', 'error'); return; } if (socketRef.current) { socketRef.current.emit('create-project', { projectData: projectForm, creatorId: currentUser.id, userRole: currentUser.role }); } setShowCreateProject(false); setProjectForm({ name: '', description: '' }); }, [currentUser, projectForm, addNotification]); const joinProject = useCallback((project) => { setCurrentProject(project); setProjectContent(project.content); if (socketRef.current) { socketRef.current.emit('join-project', { projectId: project.id, user: currentUser }); } addNotification(`Joined "${project.name}"`, 'success'); }, [currentUser, addNotification]); const handleContentChange = useCallback((newContent) => { setProjectContent(newContent); if (socketRef.current && currentProject) { socketRef.current.emit('content-update', { projectId: currentProject.id, content: newContent, userId: currentUser.id }); } }, [currentProject, currentUser]); const inviteUsers = useCallback(() => { if (!currentProject || selectedUsers.length === 0) return; selectedUsers.forEach(userId => { if (socketRef.current) { socketRef.current.emit('send-invitation', { projectId: currentProject.id, targetUserId: userId, invitedByUserId: currentUser.id, role: 'editor' }); } }); setSelectedUsers([]); addNotification(`Invitations sent to ${selectedUsers.length} users!`, 'success'); }, [currentProject, selectedUsers, currentUser, addNotification]); const logout = useCallback(() => { localStorage.removeItem('saas_user'); if (socketRef.current) socketRef.current.disconnect(); setCurrentUser(null); setIsAuthenticated(false); setShowLogin(true); setProjects([]); setCurrentProject(null); addNotification('Logged out', 'info'); }, [addNotification]); const isSuperAdmin = currentUser?.role === 'super_admin'; if (showLogin) { return React.createElement('div', { className: 'auth-container' }, React.createElement('div', { className: 'auth-card' }, React.createElement('h1', { style: { textAlign: 'center', marginBottom: '24px', fontSize: '24px', fontWeight: '700' } }, '🚀 SaaS Collaboration'), React.createElement('form', { onSubmit: handleLogin }, React.createElement('input', { type: 'email', className: 'form-input', placeholder: 'Email', value: loginForm.email, onChange: (e) => setLoginForm(prev => ({ ...prev, email: e.target.value })), required: true }), React.createElement('input', { type: 'password', className: 'form-input', placeholder: 'Password', value: loginForm.password, onChange: (e) => setLoginForm(prev => ({ ...prev, password: e.target.value })), required: true }), React.createElement('button', { type: 'submit', className: 'btn btn-primary', disabled: loading, style: { width: '100%' } }, loading ? 'Signing In...' : 'Sign In') ), React.createElement('div', { style: { marginTop: '20px', padding: '16px', background: '#f8fafc', borderRadius: '6px', fontSize: '14px' } }, React.createElement('p', { style: { fontWeight: '600', marginBottom: '8px' } }, 'Test Credentials:'), React.createElement('p', null, 'Super Admin: superadmin@realtimecursor.com / admin123'), React.createElement('p', null, 'User: john@example.com / any password') ) ) ); } return React.createElement('div', { className: 'saas-platform' }, // Notifications notifications.map(notification => React.createElement('div', { key: notification.id, className: `notification ${notification.type}` }, notification.message) ), // Header React.createElement('header', { className: 'header' }, React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '16px' } }, React.createElement('h1', { style: { fontSize: '20px', fontWeight: '700', margin: 0 } }, '🚀 SaaS Collaboration'), React.createElement('div', { className: `status-indicator ${connected ? 'status-connected' : 'status-disconnected'}` }, React.createElement('div', { className: 'status-dot' }), connected ? 'Connected' : 'Disconnected' ) ), React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '12px' } }, React.createElement('span', { style: { fontSize: '14px' } }, `${currentUser?.name} (${currentUser?.role})`), isSuperAdmin && React.createElement('button', { onClick: () => setShowCreateProject(true), className: 'btn btn-success', style: { padding: '8px 16px', fontSize: '14px' } }, '+ Project'), isSuperAdmin && React.createElement('button', { onClick: () => { setShowAdminPanel(!showAdminPanel); if (!showAdminPanel && socketRef.current) { socketRef.current.emit('get-all-users'); } }, className: 'btn btn-primary', style: { padding: '8px 16px', fontSize: '14px' } }, 'Admin'), React.createElement('button', { onClick: logout, className: 'btn btn-danger', style: { padding: '8px 16px', fontSize: '14px' } }, 'Logout') ) ), // Admin Panel showAdminPanel && isSuperAdmin && React.createElement('div', { style: { background: 'white', margin: '24px', padding: '24px', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' } }, React.createElement('h2', { style: { fontSize: '18px', fontWeight: '700', marginBottom: '16px' } }, 'Admin Panel'), React.createElement('h3', { style: { fontSize: '16px', marginBottom: '12px' } }, `Users (${allUsers.length})`), React.createElement('div', { className: 'user-grid' }, allUsers.map(user => React.createElement('div', { key: user.id, className: 'user-card' }, React.createElement('div', null, React.createElement('div', { style: { fontWeight: '600' } }, user.name), React.createElement('div', { style: { fontSize: '14px', color: '#6b7280' } }, user.email), React.createElement('span', { style: { fontSize: '12px', padding: '2px 6px', borderRadius: '4px', background: user.role === 'super_admin' ? '#fce7f3' : '#dbeafe', color: user.role === 'super_admin' ? '#be185d' : '#2563eb' } }, user.role) ), React.createElement('button', { onClick: () => { if (selectedUsers.includes(user.id)) { setSelectedUsers(prev => prev.filter(id => id !== user.id)); } else { setSelectedUsers(prev => [...prev, user.id]); } }, className: `btn ${selectedUsers.includes(user.id) ? 'btn-success' : 'btn-primary'}`, style: { padding: '6px 12px', fontSize: '12px' } }, selectedUsers.includes(user.id) ? 'Selected' : 'Select') ) ) ), selectedUsers.length > 0 && currentProject && React.createElement('div', { style: { marginTop: '16px', padding: '16px', background: '#eff6ff', borderRadius: '6px', border: '1px solid #3b82f6', display: 'flex', justifyContent: 'space-between', alignItems: 'center' } }, React.createElement('div', null, React.createElement('strong', null, `Ready to invite ${selectedUsers.length} users`), React.createElement('div', { style: { fontSize: '14px', color: '#6b7280' } }, `To: ${currentProject.name}`) ), React.createElement('button', { onClick: inviteUsers, className: 'btn btn-primary' }, 'Send Invitations') ) ), // Main Content React.createElement('div', { className: 'main-container' }, // Sidebar React.createElement('div', { className: 'sidebar' }, React.createElement('h3', { style: { fontSize: '16px', fontWeight: '700', marginBottom: '16px' } }, isSuperAdmin ? 'All Projects' : 'My Projects' ), projects.length === 0 ? React.createElement('div', { style: { textAlign: 'center', padding: '20px', color: '#6b7280' } }, React.createElement('p', null, isSuperAdmin ? 'No projects yet' : 'No projects assigned') ) : React.createElement('div', null, projects.map(project => React.createElement('div', { key: project.id, className: `project-card ${currentProject?.id === project.id ? 'active' : ''}`, onClick: () => joinProject(project) }, React.createElement('div', { style: { fontWeight: '600', marginBottom: '4px' } }, project.name), React.createElement('div', { style: { fontSize: '14px', color: '#6b7280' } }, project.description), React.createElement('div', { style: { fontSize: '12px', color: '#9ca3af', marginTop: '8px' } }, `${project.memberCount || 1} members` ) ) ) ) ), // Editor React.createElement('div', { className: 'editor-container' }, currentProject ? [ React.createElement('div', { key: 'header', className: 'editor-header' }, React.createElement('h3', { style: { fontSize: '18px', fontWeight: '700', margin: 0 } }, currentProject.name), React.createElement('div', { style: { fontSize: '14px', color: '#6b7280' } }, `${collaborators.length} online` ) ), React.createElement(RichEditor, { key: 'editor', content: projectContent, onChange: handleContentChange, currentUser: currentUser, projectId: currentProject.id, socket: socketRef.current, collaborators: collaborators, typingUsers: typingUsers }), React.createElement('div', { key: 'collaborators', className: 'collaborators-bar' }, React.createElement('span', { style: { fontWeight: '600', fontSize: '14px' } }, 'Collaborators:'), ...collaborators.map(collaborator => React.createElement('div', { key: collaborator.id, className: 'collaborator-avatar', style: { background: collaborator.color || '#3b82f6' }, title: collaborator.name }, collaborator.name.charAt(0)) ), collaborators.length === 0 && React.createElement('span', { style: { color: '#9ca3af', fontStyle: 'italic' } }, 'No collaborators online') ) ] : React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#6b7280', textAlign: 'center' } }, React.createElement('div', { style: { fontSize: '48px', marginBottom: '16px' } }, '🚀'), React.createElement('h3', { style: { fontSize: '20px', marginBottom: '8px' } }, 'Select a project to collaborate'), React.createElement('p', null, isSuperAdmin ? 'Choose a project or create a new one' : 'Choose a project you\'ve been invited to') ) ) ), // Create Project Modal showCreateProject && React.createElement('div', { className: 'modal-overlay' }, React.createElement('div', { className: 'modal' }, React.createElement('h2', { style: { fontSize: '18px', fontWeight: '700', marginBottom: '16px' } }, 'Create New Project'), React.createElement('form', { onSubmit: createProject }, React.createElement('input', { type: 'text', className: 'form-input', placeholder: 'Project Name', value: projectForm.name, onChange: (e) => setProjectForm(prev => ({ ...prev, name: e.target.value })), required: true }), React.createElement('textarea', { className: 'form-input', placeholder: 'Description (optional)', value: projectForm.description, onChange: (e) => setProjectForm(prev => ({ ...prev, description: e.target.value })), style: { minHeight: '80px', resize: 'vertical' } }), React.createElement('div', { style: { display: 'flex', gap: '12px', justifyContent: 'flex-end' } }, React.createElement('button', { type: 'button', onClick: () => setShowCreateProject(false), className: 'btn btn-secondary' }, 'Cancel'), React.createElement('button', { type: 'submit', className: 'btn btn-primary' }, 'Create') ) ) ) ) ); }; export default SaaSCollaboration;