"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 { WifiOff, RefreshCw, } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { CommentsPanel } from "@/components/RightPanel"; 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 [isModalOpen, setIsModalOpen] = useState(false); 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 [windowWidth, setWindowWidth] = useState(0); const [leftPanelForced, setLeftPanelForced] = useState(false); const [rightPanelForced, setRightPanelForced] = useState(false); const [popupMessage, setPopupMessage] = useState(null); const contentRef = useRef(content); useEffect(() => { contentRef.current = content; }, [content]); useEffect(() => { setIsClient(true); // Set initial window width const handleResize = () => { const newWidth = window.innerWidth; setWindowWidth(newWidth); // Force immediate panel state reset when crossing the breakpoint to larger if (newWidth >= 1280) { setLeftPanelForced(false); setRightPanelForced(false); } }; // Set initial value handleResize(); // Add event listener window.addEventListener('resize', handleResize); // Cleanup return () => { window.removeEventListener('resize', handleResize); }; }, []); // Calculate panel visibility based on window width // Minimum width needed: 320px (left) + 640px (main content) + 320px (right) = 1280px const shouldShowPanels = windowWidth >= 1280; // Auto-hide forced panels when screen size increases (do this before calculating visibility) useEffect(() => { if (shouldShowPanels) { setLeftPanelForced(false); setRightPanelForced(false); } }, [shouldShowPanels]); // Calculate final panel visibility - when shouldShowPanels is true, always show panels regardless of forced state const showLeftPanel = shouldShowPanels || (!shouldShowPanels && leftPanelForced); const showRightPanel = shouldShowPanels || (!shouldShowPanels && rightPanelForced); // 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]); // 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 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); } }; const showPopup = (message: string) => { setPopupMessage(message); setTimeout(() => setPopupMessage(null), 2000); }; if (!isClient) return null; if (!roomCode) { router.push("/"); return null; } return (
copy room code: {roomCode} 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 */} setRightPanelForced(!rightPanelForced)} selectedLineStart={selectedLineStart} selectedLineEnd={selectedLineEnd} onCommentSelect={handleCommentSelect} comments={comments} onAddComment={handleAddComment} currentUser={currentUser} /> {/* Custom Popup */} {popupMessage && (
{popupMessage}
)} {/* Overlay for mobile when panels are forced open */} {!shouldShowPanels && (leftPanelForced || rightPanelForced) && (
{ setLeftPanelForced(false); setRightPanelForced(false); }} /> )} {/* Left Panel (Users, Media & ECG) */} {/* Comments Panel */} {showDisconnectToast && (
{/* Blurred overlay */}
{/* Toast */}
Connection Lost
)}
); }; const SkeletonMirror = () => { return (
); }; const RoomWrapper = () => ( }>
); export default RoomWrapper;