"use client"; import { useEffect, useState, useCallback, useRef, Suspense, useMemo } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; import { Link2, LogOut, Sun, Moon, Upload, WifiOff, RefreshCw } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { CommentsPanel } from "@/components/CommentsPanel"; import { CodeEditor, CodeEditorRef } from "@/components/CodeEditor"; import { LeftPanel } from "@/components/LeftPanel"; import { getThemeById, getNextTheme, saveThemeToCookie, getThemeFromCookie, applyTheme } from "@/lib/themes"; import debounce from "lodash/debounce"; import dotenv from "dotenv"; import { JetBrains_Mono } from "next/font/google"; import { ThemeProvider } from "next-themes"; dotenv.config(); const jetbrainsMono = JetBrains_Mono({ weight: ["400", "500", "700"], subsets: ["latin"], variable: "--font-jetbrains-mono", }); interface TextUpdate { type: "text-update"; content: string; code: string; } interface InitialContent { type: "initial-content"; content: string; code: string; } interface JoinRoom { type: "join-room"; code: string; user?: User; } interface PingMessage { type: "ping"; code: string; } interface PongMessage { type: "pong"; code: string; } interface User { id: string; name: string; color: string; lastSeen: Date; isTyping?: boolean; currentLine?: number; } interface Comment { id: string; lineNumber: number | null; lineRange?: string; author: string; authorId: string; content: string; timestamp: Date; } interface CommentMessage { type: "comment-add" | "comment-update" | "comment-delete"; code: string; comment: Comment; } interface CommentsSync { type: "comments-sync"; code: string; comments: Comment[]; } interface UserMessage { type: "user-joined" | "user-left"; code: string; user: User; } interface UsersSync { type: "users-sync"; code: string; users: User[]; } interface UserActivity { type: "user-activity"; code: string; userId: string; isTyping: boolean; currentLine?: number; } interface MediaFile { id: string; name: string; type: string; size: number; url: string; uploadedAt: Date; uploadedBy: string; } interface MediaMessage { type: "media-upload" | "media-delete"; code: string; media: MediaFile; } interface MediaSync { type: "media-sync"; code: string; mediaFiles: MediaFile[]; } type Message = | TextUpdate | InitialContent | JoinRoom | PingMessage | PongMessage | CommentMessage | CommentsSync | UserMessage | UsersSync | UserActivity | MediaMessage | MediaSync; const WS_URL = `${process.env.NEXT_PUBLIC_WS_URL}`; // Utility functions const generateUserId = () => `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const generateUserName = () => { const adjectives = ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Brown"]; const nouns = ["Cat", "Dog", "Bird", "Fish", "Bear", "Lion", "Tiger", "Wolf"]; return `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${nouns[Math.floor(Math.random() * nouns.length)]}`; }; const generateUserColor = () => { const colors = ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e67e22", "#34495e"]; return colors[Math.floor(Math.random() * colors.length)]; }; const Room = () => { const router = useRouter(); const searchParams = useSearchParams(); const roomCode = searchParams.get("code"); const socketRef = useRef(null); const editorRef = useRef(null); const fileInputRef = useRef(null); const [isClient, setIsClient] = useState(false); const [content, setContent] = useState(""); const [status, setStatus] = useState("Disconnected"); const [error, setError] = useState(""); const [commentsVisible, setCommentsVisible] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); const [windowWidth, setWindowWidth] = useState(0); const [showDisconnectToast, setShowDisconnectToast] = useState(false); const [currentThemeId, setCurrentThemeId] = useState('one-dark-pro'); const [selectedLineStart, setSelectedLineStart] = useState(); const [selectedLineEnd, setSelectedLineEnd] = useState(); const [comments, setComments] = useState([]); const [users, setUsers] = useState([]); const [mediaFiles, setMediaFiles] = useState([]); const [currentUser, setCurrentUser] = useState(null); const contentRef = useRef(content); useEffect(() => { contentRef.current = content; }, [content]); useEffect(() => { setIsClient(true); }, []); // Initialize theme from cookie useEffect(() => { if (isClient) { const savedThemeId = getThemeFromCookie(); if (savedThemeId) { const savedTheme = getThemeById(savedThemeId); if (savedTheme) { setCurrentThemeId(savedThemeId); applyTheme(savedTheme); } } else { // Apply default theme const defaultTheme = getThemeById('one-dark-pro'); if (defaultTheme) { applyTheme(defaultTheme); } } } }, [isClient]); // Apply theme when currentThemeId changes useEffect(() => { const currentTheme = getThemeById(currentThemeId); if (currentTheme) { applyTheme(currentTheme); } }, [currentThemeId]); useEffect(() => { const handleResize = () => { setWindowWidth(window.innerWidth); }; // Set initial width handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // Show disconnect toast only if still disconnected after a delay useEffect(() => { let showTimer: NodeJS.Timeout | null = null; let hideTimer: NodeJS.Timeout | null = null; if (status === "Disconnected") { // Wait 800ms before showing toast showTimer = setTimeout(() => { setShowDisconnectToast(true); // Auto-hide after 10 seconds hideTimer = setTimeout(() => { setShowDisconnectToast(false); }, 10000); }, 800); } else { setShowDisconnectToast(false); } return () => { if (showTimer) clearTimeout(showTimer); if (hideTimer) clearTimeout(hideTimer); }; }, [status]); // Calculate panel visibility based on viewport width // Left panel (256px) + Right panel (320px) + Main content (1280px) + padding (~100px) = ~1956px const shouldHidePanels = windowWidth > 0 && windowWidth < 1700; const leftPanelVisible = !shouldHidePanels; const commentsVisibleResponsive = commentsVisible && !shouldHidePanels; const debouncedSend = useMemo( () => debounce((ws: WebSocket, content: string, code: string) => { if (ws.readyState === WebSocket.OPEN) { const message: TextUpdate = { type: "text-update", content, code, }; ws.send(JSON.stringify(message)); } }, 100), [] ); const connectSocket = useCallback(() => { if (!roomCode || socketRef.current?.readyState === WebSocket.OPEN) return; if (socketRef.current) { socketRef.current.close(); } const ws = new WebSocket(WS_URL); socketRef.current = ws; ws.onopen = () => { setStatus("Connected"); setError(""); // Create user if not exists const user: User = { id: generateUserId(), name: generateUserName(), color: generateUserColor(), lastSeen: new Date(), isTyping: false }; setCurrentUser(user); const message: JoinRoom = { type: "join-room", code: roomCode, user: user }; ws.send(JSON.stringify(message)); }; ws.onmessage = (event) => { const message: Message = JSON.parse(event.data); switch (message.type) { case "initial-content": case "text-update": if (message.content !== contentRef.current) { setContent(message.content); } break; case "pong": // Handle pong response break; case "comments-sync": setComments(message.comments ? message.comments.map(c => ({ ...c, timestamp: new Date(c.timestamp) })) : []); break; case "comment-add": setComments(prev => [...prev, { ...message.comment, timestamp: new Date(message.comment.timestamp) }]); break; case "comment-update": setComments(prev => prev.map(c => c.id === message.comment.id ? { ...message.comment, timestamp: new Date(message.comment.timestamp) } : c )); break; case "comment-delete": setComments(prev => prev.filter(c => c.id !== message.comment.id)); break; case "users-sync": setUsers(message.users ? message.users.map(u => ({ ...u, lastSeen: new Date(u.lastSeen) })) : []); break; case "user-joined": setUsers(prev => [...prev, { ...message.user, lastSeen: new Date(message.user.lastSeen) }]); break; case "user-left": setUsers(prev => prev.filter(u => u.id !== message.user.id)); break; case "user-activity": setUsers(prev => prev.map(u => u.id === message.userId ? { ...u, isTyping: message.isTyping, currentLine: message.currentLine, lastSeen: new Date() } : u )); break; case "media-sync": setMediaFiles(message.mediaFiles ? message.mediaFiles.map(m => ({ ...m, uploadedAt: new Date(m.uploadedAt) })) : []); break; case "media-upload": setMediaFiles(prev => [...prev, { ...message.media, uploadedAt: new Date(message.media.uploadedAt) }]); break; case "media-delete": setMediaFiles(prev => prev.filter(m => m.id !== message.media.id)); break; } }; ws.onclose = () => { setStatus("Disconnected"); setTimeout(() => { if (socketRef.current === ws) { socketRef.current = null; } }, 0); }; ws.onerror = () => { setTimeout(() => { if (socketRef.current === ws) { connectSocket(); } }, 1000); }; }, [roomCode]); useEffect(() => { if (!isClient || !roomCode) return; connectSocket(); const pingInterval = setInterval(() => { if (socketRef.current?.readyState === WebSocket.OPEN) { socketRef.current.send(JSON.stringify({ type: "ping" })); } }, 30000); return () => { clearInterval(pingInterval); if (socketRef.current) { socketRef.current.close(); socketRef.current = null; } debouncedSend.cancel(); }; }, [roomCode, isClient, connectSocket, debouncedSend]); const handleContentChange = (newContent: string) => { setContent(newContent); if (socketRef.current?.readyState === WebSocket.OPEN) { debouncedSend(socketRef.current, newContent, roomCode!); } else if (status === "Disconnected") { debouncedSend.cancel(); connectSocket(); } }; const handleSelectionChange = (lineStart: number, lineEnd: number) => { setSelectedLineStart(lineStart); setSelectedLineEnd(lineEnd); }; const handleCommentSelect = (lineNumber: number, lineRange?: string) => { if (editorRef.current) { if (lineRange) { // Parse line range like "5-8" const [start, end] = lineRange.split('-').map(n => parseInt(n.trim())); editorRef.current.selectLines(start, end); } else { editorRef.current.selectLines(lineNumber); } } }; const handleAddComment = (content: string, lineNumber?: number, lineRange?: string) => { if (!socketRef.current || !currentUser) return; const comment: Comment = { id: '', // Will be set by server lineNumber: lineNumber || null, lineRange: lineRange, author: currentUser.name, authorId: currentUser.id, content: content, timestamp: new Date() }; const message: CommentMessage = { type: "comment-add", code: roomCode!, comment: comment }; socketRef.current.send(JSON.stringify(message)); }; const handleFileUpload = async (files: FileList) => { if (!files || files.length === 0 || !currentUser) return; const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081'; for (let i = 0; i < files.length; i++) { const file = files[i]; try { // Create form data for file upload const formData = new FormData(); formData.append('file', file); formData.append('roomCode', roomCode!); formData.append('uploadedBy', currentUser.name); // Upload file to HTTP server const response = await fetch(`${httpUrl}/upload`, { method: 'POST', body: formData, }); if (!response.ok) { throw new Error(`Upload failed: ${response.statusText}`); } const mediaFile: MediaFile = await response.json(); // Don't add to local state here - the WebSocket broadcast will handle it // This prevents duplicate entries when the server broadcasts the upload console.log('File uploaded successfully:', mediaFile); } catch (error) { console.error('Error uploading file:', error); // You could show a toast notification here } } }; const handleFileDelete = async (fileId: string) => { if (!roomCode) return; const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081'; try { const response = await fetch(`${httpUrl}/delete/${roomCode}/${fileId}`, { method: 'DELETE', }); if (!response.ok) { throw new Error(`Delete failed: ${response.statusText}`); } // Don't remove from local state here - the WebSocket broadcast will handle it // This prevents issues when the server broadcasts the deletion console.log('File deleted successfully'); } catch (error) { console.error('Error deleting file:', error); } }; if (!isClient) return null; if (!roomCode) { router.push("/"); return null; } return (
copy room code copy link to this page return to home
upload files {getThemeById(currentThemeId)?.name || 'Switch theme'}
{error && status !== "Connected" && (
{error}
)}
{/* Hidden file input */} { if (e.target.files) { handleFileUpload(e.target.files); // Reset the input so the same file can be selected again e.target.value = ''; } }} /> {/* Comments Panel */} setCommentsVisible(!commentsVisible)} selectedLineStart={selectedLineStart} selectedLineEnd={selectedLineEnd} onCommentSelect={handleCommentSelect} comments={comments} onAddComment={handleAddComment} currentUser={currentUser} /> {/* Left Panel (Users, Media & ECG) */} {leftPanelVisible && (
)} {/* Disconnect Toast */} {showDisconnectToast && (
{/* Blurred overlay */}
{/* Toast */}
Connection Lost
)}
); }; const SkeletonMirror = () => { return (
); }; const RoomWrapper = () => ( }>
); export default RoomWrapper;