UNPKG

sourabhrealtime

Version:

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

955 lines (891 loc) 31.6 kB
import React, { useState, useEffect, useRef, useCallback } from 'react'; import { supabaseAuth } from './auth.js'; export const UserRole = { SUPER_ADMIN: 'super_admin', ADMIN: 'admin', EDITOR: 'editor', VIEWER: 'viewer' }; const RealtimeApp = ({ apiUrl = 'http://localhost:3002' }) => { const [currentUser, setCurrentUser] = useState(null); const [currentProject, setCurrentProject] = useState(null); const [projects, setProjects] = useState([]); const [content, setContent] = useState('<h1>Welcome!</h1><p>Start typing...</p>'); const [showLogin, setShowLogin] = useState(true); const [showSignup, setShowSignup] = useState(false); const [showCreateProject, setShowCreateProject] = useState(false); const [showAdminPanel, setShowAdminPanel] = useState(false); const [loginForm, setLoginForm] = useState({ email: '', password: '' }); const [signupForm, setSignupForm] = useState({ email: '', password: '', name: '', role: 'editor' }); const [projectForm, setProjectForm] = useState({ name: '', description: '' }); const [collaborators, setCollaborators] = useState([]); const [connected, setConnected] = useState(false); const [notifications, setNotifications] = useState([]); const [supabaseUsers, setSupabaseUsers] = useState([]); const [selectedUserToInvite, setSelectedUserToInvite] = useState(''); const [loading, setLoading] = useState(false); const socketRef = useRef(null); const isLocalChange = useRef(false); 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)); }, 3000); }, []); const handleLogin = useCallback(async (e) => { e.preventDefault(); setLoading(true); try { const result = await supabaseAuth.login(loginForm.email, loginForm.password); if (result.success) { setCurrentUser(result.user); setShowLogin(false); addNotification('Login successful!', 'success'); loadProjects(); connectSocket(result.user); if (result.user.role === UserRole.ADMIN || result.user.role === UserRole.SUPER_ADMIN) { loadSupabaseUsers(); } } else { addNotification(result.message || 'Login failed', 'error'); } } catch (error) { addNotification('Login failed', 'error'); } finally { setLoading(false); } }, [loginForm, addNotification]); const handleSignup = useCallback(async (e) => { e.preventDefault(); setLoading(true); try { const result = await supabaseAuth.signup( signupForm.email, signupForm.password, signupForm.name, signupForm.role ); if (result.success) { addNotification('Account created! You can now login.', 'success'); setShowSignup(false); setSignupForm({ email: '', password: '', name: '', role: 'editor' }); } else { addNotification(result.message || 'Signup failed', 'error'); } } catch (error) { addNotification('Signup failed', 'error'); } finally { setLoading(false); } }, [signupForm, addNotification]); const loadSupabaseUsers = useCallback(async () => { try { const users = await supabaseAuth.getAllUsers(); setSupabaseUsers(users); addNotification(`Loaded ${users.length} users`, 'success'); } catch (error) { addNotification('Failed to load users', 'error'); } }, [addNotification]); const connectSocket = useCallback((user) => { if (socketRef.current) return; try { const io = require('socket.io-client'); const socket = io(apiUrl); socketRef.current = socket; socket.on('connect', () => { setConnected(true); addNotification('Connected', 'success'); }); socket.on('disconnect', () => { setConnected(false); addNotification('Disconnected', 'warning'); }); socket.on('room-users', (data) => { if (data?.users) { setCollaborators(data.users.filter(u => u.id !== user.id)); } }); socket.on('user-joined', (data) => { if (data?.user && data.user.id !== user.id) { setCollaborators(prev => [...prev.filter(u => u.id !== data.user.id), data.user]); addNotification(`${data.user.name} joined`, 'info'); } }); socket.on('user-left', (data) => { if (data?.userId) { setCollaborators(prev => prev.filter(u => u.id !== data.userId)); } }); socket.on('content-update', (data) => { if (data?.content !== undefined && !isLocalChange.current) { setContent(data.content); } }); } catch (error) { addNotification('Failed to connect', 'error'); } }, [apiUrl, addNotification]); const loadProjects = useCallback(async () => { try { const response = await fetch(`${apiUrl}/api/projects`); const data = await response.json(); if (data.success) { setProjects(data.projects); } } catch (error) { addNotification('Failed to load projects', 'error'); } }, [apiUrl, addNotification]); const createProject = useCallback(async (e) => { e.preventDefault(); try { const response = await fetch(`${apiUrl}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'user-id': currentUser.id }, body: JSON.stringify(projectForm) }); const data = await response.json(); if (data.success) { setProjects(prev => [...prev, data.project]); setShowCreateProject(false); setProjectForm({ name: '', description: '' }); addNotification('Project created!', 'success'); } } catch (error) { addNotification('Failed to create project', 'error'); } }, [apiUrl, currentUser, projectForm, addNotification]); const joinProject = useCallback((projectId) => { setCurrentProject(projectId); if (socketRef.current && currentUser) { socketRef.current.emit('join-project', { projectId, user: currentUser }); } addNotification('Joined project!', 'success'); }, [currentUser, addNotification]); const handleContentChange = useCallback((e) => { const newContent = e.target.innerHTML; isLocalChange.current = true; setContent(newContent); if (socketRef.current && currentProject) { socketRef.current.emit('content-update', { projectId: currentProject, content: newContent, version: Date.now() }); } setTimeout(() => { isLocalChange.current = false; }, 100); }, [currentProject]); const inviteUserToProject = useCallback(() => { if (socketRef.current && currentProject && selectedUserToInvite) { const selectedUser = supabaseUsers.find(u => u.id === selectedUserToInvite); if (selectedUser) { socketRef.current.emit('invite-user', { projectId: currentProject, email: selectedUser.email, role: UserRole.EDITOR, invitedBy: currentUser.id }); setSelectedUserToInvite(''); addNotification(`Invited ${selectedUser.name}!`, 'success'); } } }, [socketRef, currentProject, selectedUserToInvite, supabaseUsers, currentUser, addNotification]); const formatText = useCallback((command, value = null) => { document.execCommand(command, false, value); const editor = document.getElementById('editor'); if (editor) { handleContentChange({ target: editor }); } }, [handleContentChange]); const isAdmin = currentUser?.role === UserRole.ADMIN || currentUser?.role === UserRole.SUPER_ADMIN; if (showSignup) { return ( <div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}> <div style={{ background: 'white', padding: '40px', borderRadius: '20px', boxShadow: '0 20px 40px rgba(0,0,0,0.1)', width: '400px', textAlign: 'center' }}> <h1 style={{ marginBottom: '30px', color: '#333' }}>🚀 Create Account</h1> <form onSubmit={handleSignup}> <input type="text" placeholder="Full Name" value={signupForm.name} onChange={(e) => setSignupForm(prev => ({ ...prev, name: e.target.value }))} style={{ width: '100%', padding: '12px', margin: '10px 0', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px' }} required /> <input type="email" placeholder="Email" value={signupForm.email} onChange={(e) => setSignupForm(prev => ({ ...prev, email: e.target.value }))} style={{ width: '100%', padding: '12px', margin: '10px 0', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px' }} required /> <input type="password" placeholder="Password" value={signupForm.password} onChange={(e) => setSignupForm(prev => ({ ...prev, password: e.target.value }))} style={{ width: '100%', padding: '12px', margin: '10px 0', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px' }} required /> <select value={signupForm.role} onChange={(e) => setSignupForm(prev => ({ ...prev, role: e.target.value }))} style={{ width: '100%', padding: '12px', margin: '10px 0', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px' }} > <option value="editor">Editor</option> <option value="admin">Admin</option> <option value="super_admin">Super Admin</option> </select> <button type="submit" disabled={loading} style={{ width: '100%', padding: '12px', background: loading ? '#6c757d' : '#28a745', color: 'white', border: 'none', borderRadius: '8px', fontSize: '16px', cursor: loading ? 'not-allowed' : 'pointer', marginTop: '10px' }} > {loading ? 'Creating...' : 'Create Account'} </button> </form> <button onClick={() => setShowSignup(false)} style={{ marginTop: '20px', background: 'none', border: 'none', color: '#007bff', cursor: 'pointer', textDecoration: 'underline' }} > Back to Login </button> </div> </div> ); } if (showLogin) { return ( <div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}> <div style={{ background: 'white', padding: '40px', borderRadius: '20px', boxShadow: '0 20px 40px rgba(0,0,0,0.1)', width: '400px', textAlign: 'center' }}> <h1 style={{ marginBottom: '30px', color: '#333' }}>🚀 Supabase Collaboration</h1> <form onSubmit={handleLogin}> <input type="email" placeholder="Email" value={loginForm.email} onChange={(e) => setLoginForm(prev => ({ ...prev, email: e.target.value }))} style={{ width: '100%', padding: '12px', margin: '10px 0', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px' }} required /> <input type="password" placeholder="Password" value={loginForm.password} onChange={(e) => setLoginForm(prev => ({ ...prev, password: e.target.value }))} style={{ width: '100%', padding: '12px', margin: '10px 0', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px' }} required /> <button type="submit" disabled={loading} style={{ width: '100%', padding: '12px', background: loading ? '#6c757d' : '#007bff', color: 'white', border: 'none', borderRadius: '8px', fontSize: '16px', cursor: loading ? 'not-allowed' : 'pointer', marginTop: '10px' }} > {loading ? 'Logging in...' : 'Login'} </button> </form> <button onClick={() => setShowSignup(true)} style={{ marginTop: '20px', background: 'none', border: 'none', color: '#007bff', cursor: 'pointer', textDecoration: 'underline' }} > Create Account </button> <div style={{ marginTop: '20px', padding: '15px', background: '#f8f9fa', borderRadius: '8px', fontSize: '14px', color: '#666' }}> <p><strong>Admin Credentials Created:</strong></p> <p>Admin: admin@collaboration.com / admin123456</p> <p>Super Admin: superadmin@collaboration.com / superadmin123</p> </div> </div> </div> ); } return ( <div style={{ minHeight: '100vh', background: '#f5f7fa' }}> {/* Notifications */} <div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 1000 }}> {notifications.map(notification => ( <div key={notification.id} style={{ padding: '12px 20px', margin: '5px 0', borderRadius: '8px', color: 'white', fontWeight: '500', background: notification.type === 'success' ? '#28a745' : notification.type === 'error' ? '#dc3545' : notification.type === 'warning' ? '#ffc107' : '#17a2b8' }}> {notification.message} </div> ))} </div> {/* Header */} <header style={{ background: 'white', padding: '15px 30px', boxShadow: '0 2px 10px rgba(0,0,0,0.1)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}> <h1 style={{ color: '#333', fontSize: '1.5rem' }}>🚀 Collaboration</h1> <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <div style={{ width: '8px', height: '8px', borderRadius: '50%', background: connected ? '#28a745' : '#dc3545' }}></div> <span style={{ fontSize: '0.9rem', color: '#666' }}> {connected ? 'Connected' : 'Disconnected'} </span> </div> </div> <div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}> <div style={{ textAlign: 'right' }}> <div style={{ fontWeight: '600', color: '#333' }}>{currentUser?.name}</div> <div style={{ fontSize: '0.8rem', color: '#666', textTransform: 'uppercase' }}> {currentUser?.role} </div> </div> <button onClick={() => setShowCreateProject(true)} style={{ padding: '8px 16px', background: '#28a745', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer' }} > Create Project </button> {isAdmin && ( <button onClick={() => setShowAdminPanel(!showAdminPanel)} style={{ padding: '8px 16px', background: '#17a2b8', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer' }} > Admin Panel </button> )} <button onClick={() => { if (socketRef.current) socketRef.current.disconnect(); setCurrentUser(null); setShowLogin(true); }} style={{ padding: '8px 16px', background: '#dc3545', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer' }} > Logout </button> </div> </header> {/* Admin Panel */} {showAdminPanel && isAdmin && ( <div style={{ background: 'white', margin: '20px 30px', padding: '25px', borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)' }}> <h2>Admin Panel - All Supabase Users</h2> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '15px', marginTop: '20px' }}> {supabaseUsers.map(user => ( <div key={user.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '15px', background: '#f8f9fa', borderRadius: '10px', border: '1px solid #e9ecef' }}> <div> <strong>{user.name}</strong> <div style={{ fontSize: '0.9rem', color: '#666' }}>{user.email}</div> <small style={{ color: '#999' }}>Role: {user.role}</small> </div> <button onClick={() => setSelectedUserToInvite(user.id)} style={{ padding: '6px 12px', background: selectedUserToInvite === user.id ? '#28a745' : '#007bff', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' }} > {selectedUserToInvite === user.id ? 'Selected' : 'Select'} </button> </div> ))} </div> {selectedUserToInvite && currentProject && ( <div style={{ marginTop: '20px', padding: '15px', background: '#e7f3ff', borderRadius: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <span> Invite {supabaseUsers.find(u => u.id === selectedUserToInvite)?.name} to current project? </span> <button onClick={inviteUserToProject} style={{ padding: '8px 16px', background: '#007bff', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }} > Send Invitation </button> </div> )} </div> )} {/* Main Content */} <div style={{ display: 'flex', gap: '20px', padding: '20px 30px' }}> {/* Sidebar - Projects */} <div style={{ width: '300px', background: 'white', borderRadius: '15px', padding: '20px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)', height: 'fit-content' }}> <h3 style={{ marginBottom: '20px' }}>My Projects</h3> {projects.length === 0 ? ( <p style={{ color: '#666', textAlign: 'center', padding: '20px' }}> No projects yet. Create your first project! </p> ) : ( <div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}> {projects.map(project => ( <div key={project.id} style={{ padding: '15px', border: currentProject === project.id ? '2px solid #007bff' : '2px solid #e9ecef', borderRadius: '10px', cursor: 'pointer', transition: 'all 0.2s' }} onClick={() => joinProject(project.id)}> <strong style={{ display: 'block', marginBottom: '8px' }}>{project.name}</strong> <p style={{ color: '#666', fontSize: '0.9rem', marginBottom: '8px' }}> {project.description} </p> <small style={{ color: '#999' }}> {project.memberCount || 0} members </small> </div> ))} </div> )} </div> {/* Editor */} <div style={{ flex: 1, background: 'white', borderRadius: '15px', boxShadow: '0 4px 20px rgba(0,0,0,0.1)', display: 'flex', flexDirection: 'column' }}> {currentProject ? ( <> <div style={{ padding: '20px 25px', borderBottom: '1px solid #e9ecef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <h3>Real-time Editor</h3> <div style={{ fontSize: '0.9rem', color: '#666' }}> {collaborators.length} collaborators online </div> </div> {/* Toolbar */} <div style={{ padding: '15px 25px', borderBottom: '1px solid #e9ecef', display: 'flex', gap: '10px' }}> <button onClick={() => formatText('bold')} style={{ padding: '8px 12px', border: '1px solid #e9ecef', background: 'white', borderRadius: '6px', cursor: 'pointer' }}> <strong>B</strong> </button> <button onClick={() => formatText('italic')} style={{ padding: '8px 12px', border: '1px solid #e9ecef', background: 'white', borderRadius: '6px', cursor: 'pointer' }}> <em>I</em> </button> <button onClick={() => formatText('underline')} style={{ padding: '8px 12px', border: '1px solid #e9ecef', background: 'white', borderRadius: '6px', cursor: 'pointer' }}> <u>U</u> </button> <button onClick={() => formatText('formatBlock', 'h1')} style={{ padding: '8px 12px', border: '1px solid #e9ecef', background: 'white', borderRadius: '6px', cursor: 'pointer' }}> H1 </button> <button onClick={() => formatText('formatBlock', 'h2')} style={{ padding: '8px 12px', border: '1px solid #e9ecef', background: 'white', borderRadius: '6px', cursor: 'pointer' }}> H2 </button> <button onClick={() => formatText('insertUnorderedList')} style={{ padding: '8px 12px', border: '1px solid #e9ecef', background: 'white', borderRadius: '6px', cursor: 'pointer' }}> • List </button> </div> {/* Editor */} <div id="editor" contentEditable onInput={handleContentChange} dangerouslySetInnerHTML={{ __html: content }} style={{ flex: 1, padding: '25px', minHeight: '400px', outline: 'none', fontSize: '16px', lineHeight: '1.6', background: '#fafbfc' }} /> {/* Collaborators */} <div style={{ display: 'flex', gap: '10px', padding: '15px 25px', borderTop: '1px solid #e9ecef', alignItems: 'center', minHeight: '60px' }}> <span style={{ marginRight: '10px', color: '#666' }}>Collaborators:</span> {collaborators.map(collaborator => ( <div key={collaborator.id} style={{ width: '32px', height: '32px', borderRadius: '50%', background: collaborator.color || '#007bff', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: '600', fontSize: '0.9rem' }} title={collaborator.name} > {collaborator.name.charAt(0)} </div> ))} {collaborators.length === 0 && ( <span style={{ color: '#999', fontStyle: 'italic' }}>No collaborators online</span> )} </div> </> ) : ( <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#666', textAlign: 'center' }}> <h3 style={{ marginBottom: '10px' }}>Select a project to start collaborating</h3> <p>Choose a project from the sidebar or create a new one</p> {isAdmin && ( <p style={{ marginTop: '10px', color: '#007bff' }}> As an admin, you can invite any Supabase user to your projects </p> )} </div> )} </div> </div> {/* Create Project Modal */} {showCreateProject && ( <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 }}> <div style={{ background: 'white', padding: '30px', borderRadius: '15px', width: '500px', boxShadow: '0 20px 40px rgba(0,0,0,0.2)' }}> <h2 style={{ marginBottom: '25px' }}>Create New Project</h2> <form onSubmit={createProject}> <div style={{ marginBottom: '20px' }}> <label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}> Project Name </label> <input type="text" value={projectForm.name} onChange={(e) => setProjectForm(prev => ({ ...prev, name: e.target.value }))} style={{ width: '100%', padding: '12px', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px' }} required /> </div> <div style={{ marginBottom: '20px' }}> <label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}> Description </label> <textarea value={projectForm.description} onChange={(e) => setProjectForm(prev => ({ ...prev, description: e.target.value }))} style={{ width: '100%', padding: '12px', border: '2px solid #e9ecef', borderRadius: '8px', fontSize: '16px', minHeight: '80px', resize: 'vertical' }} /> </div> <div style={{ display: 'flex', gap: '15px', justifyContent: 'flex-end' }}> <button type="button" onClick={() => setShowCreateProject(false)} style={{ padding: '10px 20px', background: '#6c757d', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer' }} > Cancel </button> <button type="submit" style={{ padding: '10px 20px', background: '#007bff', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer' }} > Create Project </button> </div> </form> </div> </div> )} </div> ); }; export default RealtimeApp;