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