commit 02a102481effb6d08e9c9e9f352e556a7f0d64ea Author: Arkaprabha Chakraborty Date: Thu Oct 30 11:04:17 2025 +0530 init diff --git a/client/.eslintrc.json b/client/.eslintrc.json new file mode 100644 index 0000000..d8d80b0 --- /dev/null +++ b/client/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } +} diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..26b002a --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..3959d43 --- /dev/null +++ b/client/README.md @@ -0,0 +1,49 @@ +# Room + +Room is a real-time collaborative text editor built using WebSockets, designed to enable multiple users to edit text simultaneously. The application provides a seamless experience for users to collaborate and share ideas in real time. + +## Features + +- **Real-Time Collaboration**: Multiple users can edit the same document simultaneously, with changes reflected instantly. +- **User-Friendly Interface**: A simple and intuitive interface designed to enhance the writing experience. +- **WebSocket Integration**: Efficient real-time communication between clients and the server. + +## Tech Stack + +- **Frontend**: Next.js 15, TypeScript, Tailwind CSS +- **WebSocket Library**: ws + +## Installation + +To get started with Room, follow these steps: + +1. **Clone the repository**: + + ```bash + git clone https://github.com/arkorty/Room.git + cd Room + ``` + +2. **Install dependencies**: + + ```bash + bun install + ``` + +3. **Run the application**: + + ```bash + bun run dev + ``` + +4. **Access the application**: Open your browser and navigate to `http://localhost:3000`. + +## Usage + +- **Creating a Room**: Users can create a new document from the dashboard. +- **Inviting Collaborators**: Share a link with collaborators to allow them to join the editing session. +- **Editing**: Start typing in the editor; changes will be reflected in real-time for all users. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/client/app/api/socket/route.ts b/client/app/api/socket/route.ts new file mode 100644 index 0000000..dc8ecf6 --- /dev/null +++ b/client/app/api/socket/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from "next/server"; +import { WebSocketServer } from "ws"; + +interface ExtendedNextRequest extends NextRequest { + socket: { + server: { + wss?: WebSocketServer; + }; + on: (event: string, listener: (...args: any[]) => void) => void; + }; +} + +export async function GET(req: ExtendedNextRequest) { + const { socket } = req; + + if (!socket?.server?.wss) { + const wss = new WebSocketServer({ noServer: true }); + socket.server.wss = wss; + + wss.on("connection", (ws) => { + ws.on("message", (message) => { + wss.clients.forEach((client) => { + if (client.readyState === client.OPEN) { + client.send(message.toString()); + } + }); + }); + }); + + socket.on("upgrade", (request: any, socket: any, head: any) => { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + }); + } + + return new Response("WebSocket setup complete"); +} diff --git a/client/app/favicon.ico b/client/app/favicon.ico new file mode 100644 index 0000000..cf08bf0 Binary files /dev/null and b/client/app/favicon.ico differ diff --git a/client/app/fonts/GeistMonoVF.woff b/client/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/client/app/fonts/GeistMonoVF.woff differ diff --git a/client/app/fonts/GeistVF.woff b/client/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/client/app/fonts/GeistVF.woff differ diff --git a/client/app/globals.css b/client/app/globals.css new file mode 100644 index 0000000..4ca8c90 --- /dev/null +++ b/client/app/globals.css @@ -0,0 +1,87 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap"); + +@layer base { + :root { + --background: rgb(251, 241, 199); + --foreground: rgb(60, 56, 54); + --card: rgb(235, 219, 178); + --card-foreground: rgb(60, 56, 54); + --popover: rgb(235, 219, 178); + --popover-foreground: rgb(60, 56, 54); + --primary: rgb(211, 134, 155); + --primary-foreground: rgb(251, 241, 199); + --secondary: rgb(184, 187, 38); + --secondary-foreground: rgb(80, 73, 69); + --muted: rgb(168, 153, 132); + --muted-foreground: rgb(102, 92, 84); + --accent: rgb(250, 189, 47); + --accent-foreground: rgb(80, 73, 69); + --destructive: rgb(204, 36, 29); + --destructive-foreground: rgb(251, 241, 199); + --border: rgb(213, 196, 161); + --input: rgb(213, 196, 161); + --ring: rgb(211, 134, 155); + --radius: 0.5rem; + --chart-1: rgb(250, 189, 47); + --chart-2: rgb(131, 165, 152); + --chart-3: rgb(69, 133, 136); + --chart-4: rgb(254, 128, 25); + --chart-5: rgb(251, 73, 52); + --info: rgb(131, 165, 152); + --info-foreground: rgb(40, 40, 40); + --warning: rgb(215, 153, 33); + --warning-foreground: rgb(40, 40, 40); + --success: rgb(184, 187, 38); + --success-foreground: rgb(40, 40, 40); + --error: rgb(251, 73, 52); + --error-foreground: rgb(40, 40, 40); + } + + .dark { + --background: rgb(40, 40, 40); + --foreground: rgb(235, 219, 178); + --card: rgb(60, 56, 54); + --card-foreground: rgb(235, 219, 178); + --popover: rgb(60, 56, 54); + --popover-foreground: rgb(235, 219, 178); + --primary: rgb(211, 134, 155); + --primary-foreground: rgb(40, 40, 40); + --secondary: rgb(80, 73, 69); + --secondary-foreground: rgb(235, 219, 178); + --muted: rgb(80, 73, 69); + --muted-foreground: rgb(168, 153, 132); + --accent: rgb(80, 73, 69); + --accent-foreground: rgb(235, 219, 178); + --destructive: rgb(204, 36, 29); + --destructive-foreground: rgb(235, 219, 178); + --border: rgb(102, 92, 84); + --input: rgb(102, 92, 84); + --ring: rgb(211, 134, 155); + --chart-1: rgb(250, 189, 47); + --chart-2: rgb(131, 165, 152); + --chart-3: rgb(69, 133, 136); + --chart-4: rgb(254, 128, 25); + --chart-5: rgb(251, 73, 52); + --info: rgb(131, 165, 152); + --info-foreground: rgb(235, 219, 178); + --warning: rgb(215, 153, 33); + --warning-foreground: rgb(235, 219, 178); + --success: rgb(184, 187, 38); + --success-foreground: rgb(235, 219, 178); + --error: rgb(251, 73, 52); + --error-foreground: rgb(235, 219, 178); + } +} + +@layer base { + body { + font-family: var(--font-roboto), sans-serif; + } + textarea { + font-family: var(--font-jetbrains-mono), monospace; + } +} diff --git a/client/app/layout.tsx b/client/app/layout.tsx new file mode 100644 index 0000000..6d1c974 --- /dev/null +++ b/client/app/layout.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from "next"; +import localFont from "next/font/local"; +import "./globals.css"; + +const geistSans = localFont({ + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", +}); +const geistMono = localFont({ + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", +}); + +export const metadata: Metadata = { + title: "Osborne", + description: "Real-time multi-user text editor with rooms.", + openGraph: { + title: "Osborne", + description: "Real-time multi-user text editor with rooms.", + url: "https://o.webark.in", + siteName: "Osborne", + images: [ + { + url: "https://o.webark.in/og-image.png", + width: 1500, + height: 768, + alt: "Osborne", + }, + ], + locale: "en_US", + type: "website", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/client/app/page.tsx b/client/app/page.tsx new file mode 100644 index 0000000..933fbba --- /dev/null +++ b/client/app/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState, useEffect, Suspense } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { Card, CardContent } from "@/components/ui/card"; +import Image from "next/image"; +import { ThemeProvider } from "next-themes"; +import { Skeleton } from "@/components/ui/skeleton"; + +const Home = () => { + const router = useRouter(); + const [newRoomCode, setNewRoomCode] = useState(""); + + useEffect(() => { + const joinRoom = () => { + if (newRoomCode) { + router.push(`/room?code=${newRoomCode.toUpperCase()}`); + } + }; + + if (newRoomCode.length === 6) { + joinRoom(); + } + }, [newRoomCode, router]); + + const createNewRoom = () => { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let code = ""; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + router.push(`/room?code=${code}`); + }; + + return ( +
+ +
+
+ Room Logo +
+
+ + setNewRoomCode(value.toUpperCase())} + maxLength={6} + pattern="[A-Z0-9]*" + inputMode="text" + > + + {[...Array(6)].map((_, index) => ( + + ))} + + + + or + + + +
+
+ ); +}; + +const SkeletonHome = () => { + return ( +
+
+
+ +
+
+ + + or + + +
+
+
+ ); +}; + +const HomeWrapper = () => ( + + }> + + + +); + +export default HomeWrapper; diff --git a/client/app/room/page.tsx b/client/app/room/page.tsx new file mode 100644 index 0000000..24c5cba --- /dev/null +++ b/client/app/room/page.tsx @@ -0,0 +1,800 @@ +"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; diff --git a/client/bun.lockb b/client/bun.lockb new file mode 100755 index 0000000..c7475e6 Binary files /dev/null and b/client/bun.lockb differ diff --git a/client/components.json b/client/components.json new file mode 100644 index 0000000..4a32d53 --- /dev/null +++ b/client/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "examples": "@/components/examples", + "blocks": "@/components/blocks" + } +} \ No newline at end of file diff --git a/client/components/CodeEditor.tsx b/client/components/CodeEditor.tsx new file mode 100644 index 0000000..974c818 --- /dev/null +++ b/client/components/CodeEditor.tsx @@ -0,0 +1,225 @@ +import React, { useRef, useEffect, forwardRef, useImperativeHandle } from 'react'; +import Editor from '@monaco-editor/react'; +import type { ThemeConfig } from '@/lib/themes'; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + onSelectionChange?: (lineStart: number, lineEnd: number) => void; + language?: string; + className?: string; + themeConfig?: ThemeConfig; +} + +export interface CodeEditorRef { + selectLines: (startLine: number, endLine?: number) => void; + focus: () => void; +} + +export const CodeEditor = forwardRef(({ + value, + onChange, + onSelectionChange, + language = 'plaintext', + className = '', + themeConfig +}, ref) => { + const editorRef = useRef(null); + const monacoRef = useRef(null); + const [editorReady, setEditorReady] = React.useState(false); + + // Expose methods to parent component + useImperativeHandle(ref, () => ({ + selectLines: (startLine: number, endLine?: number) => { + if (editorRef.current) { + const actualEndLine = endLine || startLine; + const selection = { + startLineNumber: startLine, + startColumn: 1, + endLineNumber: actualEndLine, + endColumn: editorRef.current.getModel()?.getLineMaxColumn(actualEndLine) || 1, + }; + editorRef.current.setSelection(selection); + editorRef.current.revealLineInCenter(startLine); + editorRef.current.focus(); + } + }, + focus: () => { + if (editorRef.current) { + editorRef.current.focus(); + } + }, + }), []); + + const handleEditorDidMount = (editor: any, monaco: any) => { + editorRef.current = editor; + monacoRef.current = monaco; + setEditorReady(true); + // Add selection change listener + if (onSelectionChange) { + editor.onDidChangeCursorSelection((e: any) => { + const selection = e.selection; + const startLine = selection.startLineNumber; + const endLine = selection.endLineNumber; + onSelectionChange(startLine, endLine); + }); + } + }; + + const handleEditorChange = (value: string | undefined) => { + onChange(value || ''); + }; + + // Ensure Monaco theme is always set after both editor and themeConfig are ready + useEffect(() => { + if (!editorReady || !themeConfig || !monacoRef.current) return; + // Convert HSL to hex for Monaco editor + const hslToHex = (hsl: string): string => { + const match = hsl.match(/hsl\((\d+),\s*(\d+)%\,\s*(\d+)%\)/); + if (!match) return '#000000'; + const h = parseInt(match[1]) / 360; + const s = parseInt(match[2]) / 100; + const l = parseInt(match[3]) / 100; + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + let r, g, b; + if (s === 0) { + r = g = b = l; + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + const toHex = (c: number) => { + const hex = Math.round(c * 255).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }; + const defineThemeFromConfig = (config: ThemeConfig) => { + const themeName = `osborne-${config.id}`; + const backgroundColor = hslToHex(config.colors.background); + const foregroundColor = hslToHex(config.colors.foreground); + const cardColor = hslToHex(config.colors.card); + const borderColor = hslToHex(config.colors.border); + const mutedColor = hslToHex(config.colors.muted); + const primaryColor = hslToHex(config.colors.primary); + monacoRef.current.editor.defineTheme(themeName, { + base: config.type === 'dark' ? 'vs-dark' : 'vs', + inherit: true, + rules: [ + { token: '', foreground: foregroundColor.substring(1) }, + { token: 'comment', foreground: config.type === 'dark' ? '6A9955' : '008000', fontStyle: 'italic' }, + { token: 'string', foreground: config.type === 'dark' ? 'CE9178' : 'A31515' }, + { token: 'number', foreground: config.type === 'dark' ? 'B5CEA8' : '098658' }, + { token: 'keyword', foreground: primaryColor.substring(1), fontStyle: 'bold' }, + { token: 'type', foreground: config.type === 'dark' ? '4EC9B0' : '267F99' }, + { token: 'function', foreground: config.type === 'dark' ? 'DCDCAA' : '795E26' }, + { token: 'variable', foreground: config.type === 'dark' ? '9CDCFE' : '001080' }, + ], + colors: { + 'editor.background': backgroundColor, + 'editor.foreground': foregroundColor, + 'editor.lineHighlightBackground': cardColor, + 'editor.selectionBackground': primaryColor + '40', + 'editorLineNumber.foreground': hslToHex(config.colors.mutedForeground), + 'editorLineNumber.activeForeground': foregroundColor, + 'editorGutter.background': backgroundColor, + 'editor.inactiveSelectionBackground': mutedColor, + 'editorWhitespace.foreground': borderColor, + 'editorCursor.foreground': foregroundColor, + 'editorIndentGuide.background': borderColor, + 'editorIndentGuide.activeBackground': primaryColor, + 'editor.findMatchBackground': primaryColor + '60', + 'editor.findMatchHighlightBackground': primaryColor + '30', + } + }); + return themeName; + }; + const themeName = defineThemeFromConfig(themeConfig); + monacoRef.current.editor.setTheme(themeName); + }, [themeConfig, editorReady]); + + return ( +
+ +
Loading editor...
+
+ } + /> +
+ ); +}); + +CodeEditor.displayName = 'CodeEditor'; \ No newline at end of file diff --git a/client/components/CommentsPanel.tsx b/client/components/CommentsPanel.tsx new file mode 100644 index 0000000..8bb50f9 --- /dev/null +++ b/client/components/CommentsPanel.tsx @@ -0,0 +1,179 @@ +import React, { useState, useEffect } from 'react'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { MessageSquare } from 'lucide-react'; + +interface Comment { + id: string; + lineNumber: number | null; + lineRange?: string; + author: string; + authorId?: string; + content: string; + timestamp: Date; +} + +interface User { + id: string; + name: string; + color: string; + lastSeen: Date; + isTyping?: boolean; + currentLine?: number; +} + +interface CommentsPageProps { + isVisible: boolean; + onToggle: () => void; + selectedLineStart?: number; + selectedLineEnd?: number; + onCommentSelect?: (lineNumber: number, lineRange?: string) => void; + comments?: Comment[]; + onAddComment?: (content: string, lineNumber?: number, lineRange?: string) => void; + currentUser?: User | null; +} + +export const CommentsPanel: React.FC = ({ + isVisible, + selectedLineStart, + selectedLineEnd, + onCommentSelect, + comments = [], + onAddComment, + currentUser +}) => { + const [newComment, setNewComment] = useState(''); + const [selectedLine, setSelectedLine] = useState(null); + + // Update selected line when editor selection changes + useEffect(() => { + if (selectedLineStart && selectedLineEnd) { + if (selectedLineStart === selectedLineEnd) { + setSelectedLine(selectedLineStart); + } else { + setSelectedLine(selectedLineStart); // Use start line for range selections + } + } else { + setSelectedLine(null); + } + }, [selectedLineStart, selectedLineEnd]); + + const handleAddComment = () => { + if (newComment.trim() && onAddComment && currentUser) { + const lineRange = selectedLineStart && selectedLineEnd && selectedLineStart !== selectedLineEnd + ? `${selectedLineStart}-${selectedLineEnd}` + : undefined; + + onAddComment(newComment.trim(), selectedLine || undefined, lineRange); + setNewComment(''); + } + }; + + const formatTime = (date: Date) => { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + if (!isVisible) { + return null; + } + + return ( +
+ {/* Comments List */} +
+ {comments.length === 0 ? ( +
+ +

No comments yet

+

Add a comment to get started

+
+ ) : ( + comments + .sort((a, b) => { + // Comments with line numbers come first, sorted by line number + // Comments without line numbers come last + if (a.lineNumber === null && b.lineNumber === null) return 0; + if (a.lineNumber === null) return 1; + if (b.lineNumber === null) return -1; + return a.lineNumber - b.lineNumber; + }) + .map((comment) => ( + { + if (comment.lineNumber && onCommentSelect) { + onCommentSelect(comment.lineNumber, comment.lineRange); + } + }} + > + +
+
+ + {comment.author} + + {comment.lineNumber !== null && ( + + {comment.lineRange || `Line ${comment.lineNumber}`} + + )} +
+ + {formatTime(comment.timestamp)} + +
+
+ +

+ {comment.content} +

+
+
+ )) + )} +
+ + {/* Add Comment Form */} +
+
+