diff --git a/client/app/globals.css b/client/app/globals.css
index 4ca8c90..b4e2f10 100644
--- a/client/app/globals.css
+++ b/client/app/globals.css
@@ -2,7 +2,7 @@
@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");
+@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&family=Bitcount_Grid_Single&display=swap");
@layer base {
:root {
@@ -85,3 +85,127 @@
font-family: var(--font-jetbrains-mono), monospace;
}
}
+
+/* Responsive panel behavior */
+@layer utilities {
+ @media (max-width: 1279px) {
+ .panel-responsive-hide {
+ transform: translateX(-100%);
+ }
+ .panel-responsive-hide.right-panel {
+ transform: translateX(100%);
+ }
+ }
+
+ @media (min-width: 1280px) {
+ .panel-responsive-show {
+ transform: translateX(0);
+ }
+ }
+}
+
+/* Hide scrollbars but keep functionality */
+@layer utilities {
+ .hide-scrollbar {
+ /* Firefox */
+ scrollbar-width: none;
+ /* Internet Explorer and Edge */
+ -ms-overflow-style: none;
+ }
+
+ .hide-scrollbar::-webkit-scrollbar {
+ /* WebKit browsers (Chrome, Safari, Edge) */
+ display: none;
+ width: 0;
+ height: 0;
+ }
+
+ .hide-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .hide-scrollbar::-webkit-scrollbar-thumb {
+ background: transparent;
+ }
+
+ /* Scroll shadows for indicating scrollable content */
+ .scroll-shadow {
+ position: relative;
+ }
+
+ .scroll-shadow::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 12px;
+ background: linear-gradient(to bottom,
+ hsl(var(--foreground) / 0.08),
+ hsl(var(--foreground) / 0.04),
+ transparent);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ z-index: 10;
+ }
+
+ .scroll-shadow::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 12px;
+ background: linear-gradient(to top,
+ hsl(var(--foreground) / 0.08),
+ hsl(var(--foreground) / 0.04),
+ transparent);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ z-index: 10;
+ }
+
+ .scroll-shadow.scroll-top::before {
+ opacity: 1;
+ }
+
+ .scroll-shadow.scroll-bottom::after {
+ opacity: 1;
+ }
+}
+
+/* Extremely small button styling */
+@layer utilities {
+ .btn-micro {
+ min-height: 20px;
+ line-height: 1;
+ font-size: 11px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .ui-font {
+ font-family: var(--font-bitcount-grid, "Bitcount Grid Single", monospace);
+ font-size: 1.1rem;
+ }
+
+ .otp-input {
+ background-color: var(--card);
+ color: var(--card-foreground);
+ border-color: var(--border);
+ font-family: var(--font-bitcount-grid, "Bitcount Grid Single", monospace);
+ font-size: 1.2rem;
+ font-weight: 500;
+ }
+
+ .otp-input:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 2px var(--primary);
+ outline: none;
+ }
+}
+
+
diff --git a/client/app/layout.tsx b/client/app/layout.tsx
index 6d1c974..7919a95 100644
--- a/client/app/layout.tsx
+++ b/client/app/layout.tsx
@@ -1,7 +1,14 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
+import { Bitcount_Grid_Single } from "next/font/google";
import "./globals.css";
+const bitcountGridSingle = Bitcount_Grid_Single({
+ weight: "400",
+ variable: "--font-bitcount-grid",
+ subsets: ["latin"],
+});
+
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
@@ -42,7 +49,7 @@ export default function RootLayout({
return (
{children}
diff --git a/client/app/page.tsx b/client/app/page.tsx
index 933fbba..a6494c4 100644
--- a/client/app/page.tsx
+++ b/client/app/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, Suspense } from "react";
+import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@@ -9,13 +9,81 @@ import {
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";
+import {
+ VSCODE_THEMES,
+ getThemeById,
+ applyTheme,
+ saveThemeToCookie,
+ getThemeFromCookie,
+} from "@/lib/themes";
const Home = () => {
const router = useRouter();
const [newRoomCode, setNewRoomCode] = useState("");
+ const [currentThemeIndex, setCurrentThemeIndex] = useState(0);
+ const [isClient, setIsClient] = useState(false);
+
+ const nextTheme = useCallback(() => {
+ setCurrentThemeIndex((prevIndex) => {
+ const newIndex = (prevIndex + 1) % VSCODE_THEMES.length;
+ const theme = VSCODE_THEMES[newIndex];
+ applyTheme(theme);
+ saveThemeToCookie(theme.id);
+ return newIndex;
+ });
+ }, []);
+
+ const prevTheme = useCallback(() => {
+ setCurrentThemeIndex((prevIndex) => {
+ const newIndex = prevIndex === 0
+ ? VSCODE_THEMES.length - 1
+ : prevIndex - 1;
+ const theme = VSCODE_THEMES[newIndex];
+ applyTheme(theme);
+ saveThemeToCookie(theme.id);
+ return newIndex;
+ });
+ }, []);
+
+ useEffect(() => {
+ setIsClient(true);
+
+ // Initialize theme from cookie
+ const savedThemeId = getThemeFromCookie();
+ if (savedThemeId) {
+ const themeIndex = VSCODE_THEMES.findIndex(
+ (theme) => theme.id === savedThemeId
+ );
+ if (themeIndex !== -1) {
+ setCurrentThemeIndex(themeIndex);
+ const theme = getThemeById(savedThemeId);
+ if (theme) {
+ applyTheme(theme);
+ }
+ }
+ } else {
+ // Apply default theme (first in array)
+ const defaultTheme = VSCODE_THEMES[0];
+ applyTheme(defaultTheme);
+ }
+
+ // Simple keyboard navigation
+ const handleKeyPress = (e: KeyboardEvent) => {
+ if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ prevTheme();
+ } else if (e.key === "ArrowRight") {
+ e.preventDefault();
+ nextTheme();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyPress);
+ return () => {
+ window.removeEventListener("keydown", handleKeyPress);
+ };
+ }, [nextTheme, prevTheme]);
useEffect(() => {
const joinRoom = () => {
@@ -38,21 +106,51 @@ const Home = () => {
router.push(`/room?code=${code}`);
};
+ if (!isClient) {
+ return null;
+ }
+
+ const currentTheme = VSCODE_THEMES[currentThemeIndex];
+
return (
-
-
-
-
-
+
+
+ {/* Theme Slider - Simple Version */}
+
+
+
+
+
+
+ {currentTheme.name}
+
+
+ {currentThemeIndex + 1} of {VSCODE_THEMES.length}
+
+
+
+
-
+
+
+
+ Osborne
+
+
+
setNewRoomCode(value.toUpperCase())}
@@ -62,21 +160,17 @@ const Home = () => {
>
{[...Array(6)].map((_, index) => (
-
+
))}
-
+
or
@@ -86,30 +180,9 @@ const Home = () => {
);
};
-const SkeletonHome = () => {
- return (
-
- );
-};
-
const HomeWrapper = () => (
- }>
-
-
+
);
diff --git a/client/app/room/page.tsx b/client/app/room/page.tsx
index 0a7ca85..f828ff5 100644
--- a/client/app/room/page.tsx
+++ b/client/app/room/page.tsx
@@ -1,6 +1,13 @@
"use client";
-import { useEffect, useState, useCallback, useRef, Suspense, useMemo } from "react";
+import {
+ useEffect,
+ useState,
+ useCallback,
+ useRef,
+ Suspense,
+ useMemo,
+} from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@@ -8,17 +15,20 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
-import { Link2, LogOut, Sun, Moon, Upload, WifiOff, RefreshCw } from "lucide-react";
+import {
+ WifiOff,
+ RefreshCw,
+} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
-import { CommentsPanel } from "@/components/CommentsPanel";
+import { CommentsPanel } from "@/components/RightPanel";
import { CodeEditor, CodeEditorRef } from "@/components/CodeEditor";
import { LeftPanel } from "@/components/LeftPanel";
-import {
- getThemeById,
- getNextTheme,
- saveThemeToCookie,
- getThemeFromCookie,
- applyTheme
+import {
+ getThemeById,
+ getNextTheme,
+ saveThemeToCookie,
+ getThemeFromCookie,
+ applyTheme,
} from "@/lib/themes";
import debounce from "lodash/debounce";
import dotenv from "dotenv";
@@ -134,33 +144,54 @@ interface MediaSync {
mediaFiles: MediaFile[];
}
-type Message =
- | TextUpdate
- | InitialContent
- | JoinRoom
- | PingMessage
- | PongMessage
- | CommentMessage
- | CommentsSync
- | UserMessage
- | UsersSync
- | UserActivity
- | MediaMessage
+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 generateUserId = () =>
+ `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const generateUserName = () => {
- const adjectives = ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Brown"];
+ 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)]}`;
+ 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"];
+ const colors = [
+ "#e74c3c",
+ "#3498db",
+ "#2ecc71",
+ "#f39c12",
+ "#9b59b6",
+ "#1abc9c",
+ "#e67e22",
+ "#34495e",
+ ];
return colors[Math.floor(Math.random() * colors.length)];
};
@@ -176,17 +207,19 @@ const Room = () => {
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 [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);
@@ -196,8 +229,47 @@ const Room = () => {
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) {
@@ -210,7 +282,7 @@ const Room = () => {
}
} else {
// Apply default theme
- const defaultTheme = getThemeById('one-dark-pro');
+ const defaultTheme = getThemeById("one-dark-pro");
if (defaultTheme) {
applyTheme(defaultTheme);
}
@@ -226,18 +298,6 @@ const Room = () => {
}
}, [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;
@@ -262,21 +322,19 @@ const Room = () => {
// 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),
+ () =>
+ 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),
[]
);
@@ -293,28 +351,28 @@ const Room = () => {
ws.onopen = () => {
setStatus("Connected");
setError("");
-
+
// Create user if not exists
const user: User = {
id: generateUserId(),
name: generateUserName(),
color: generateUserColor(),
lastSeen: new Date(),
- isTyping: false
+ isTyping: false,
};
setCurrentUser(user);
-
- const message: JoinRoom = {
- type: "join-room",
+
+ const message: JoinRoom = {
+ type: "join-room",
code: roomCode,
- user: user
+ 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":
@@ -322,84 +380,116 @@ const Room = () => {
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)
- })) : []);
+ 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)
- }]);
+ 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
- ));
+ 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));
+ 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)
- })) : []);
+ 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)
- }]);
+ 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));
+ 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
- ));
+ 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)
- })) : []);
+ 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)
- }]);
+ 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));
+ setMediaFiles((prev) =>
+ prev.filter((m) => m.id !== message.media.id)
+ );
break;
}
};
@@ -464,7 +554,9 @@ const Room = () => {
if (editorRef.current) {
if (lineRange) {
// Parse line range like "5-8"
- const [start, end] = lineRange.split('-').map(n => parseInt(n.trim()));
+ const [start, end] = lineRange
+ .split("-")
+ .map((n) => parseInt(n.trim()));
editorRef.current.selectLines(start, end);
} else {
editorRef.current.selectLines(lineNumber);
@@ -472,23 +564,27 @@ const Room = () => {
}
};
- const handleAddComment = (content: string, lineNumber?: number, lineRange?: string) => {
+ const handleAddComment = (
+ content: string,
+ lineNumber?: number,
+ lineRange?: string
+ ) => {
if (!socketRef.current || !currentUser) return;
const comment: Comment = {
- id: '', // Will be set by server
+ id: "", // Will be set by server
lineNumber: lineNumber || null,
lineRange: lineRange,
author: currentUser.name,
authorId: currentUser.id,
content: content,
- timestamp: new Date()
+ timestamp: new Date(),
};
const message: CommentMessage = {
type: "comment-add",
code: roomCode!,
- comment: comment
+ comment: comment,
};
socketRef.current.send(JSON.stringify(message));
@@ -496,38 +592,37 @@ const Room = () => {
const handleFileUpload = async (files: FileList) => {
if (!files || files.length === 0 || !currentUser) return;
-
- const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081';
-
+
+ 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);
-
+ 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',
+ 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);
-
+
+ console.log("File uploaded successfully:", mediaFile);
} catch (error) {
- console.error('Error uploading file:', error);
+ console.error("Error uploading file:", error);
// You could show a toast notification here
}
}
@@ -535,28 +630,32 @@ const Room = () => {
const handleFileDelete = async (fileId: string) => {
if (!roomCode) return;
-
- const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081';
-
+
+ const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8081";
+
try {
const response = await fetch(`${httpUrl}/delete/${roomCode}/${fileId}`, {
- method: 'DELETE',
+ 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');
-
+
+ console.log("File deleted successfully");
} catch (error) {
- console.error('Error deleting file:', error);
+ console.error("Error deleting file:", error);
}
};
+ const showPopup = (message: string) => {
+ setPopupMessage(message);
+ setTimeout(() => setPopupMessage(null), 2000);
+ };
+
if (!isClient) return null;
if (!roomCode) {
@@ -565,41 +664,48 @@ const Room = () => {
}
return (
-
-
-
-
-
+
+
+
+
+
- copy room code
+ copy room code: {roomCode}
@@ -609,11 +715,11 @@ const Room = () => {
@@ -621,17 +727,17 @@ const Room = () => {
-
+
@@ -641,7 +747,7 @@ const Room = () => {
- {getThemeById(currentThemeId)?.name || 'Switch theme'}
+ {getThemeById(currentThemeId)?.name || "Switch theme"}
-
+
{error && status !== "Connected" && (
{error}
@@ -680,7 +782,7 @@ const Room = () => {
-
+
{/* 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 = '';
+ e.target.value = "";
}
}}
/>
-
+
{/* Comments Panel */}
setCommentsVisible(!commentsVisible)}
+ isVisible={showRightPanel}
+ onToggle={() => setRightPanelForced(!rightPanelForced)}
selectedLineStart={selectedLineStart}
selectedLineEnd={selectedLineEnd}
onCommentSelect={handleCommentSelect}
@@ -708,23 +810,38 @@ const Room = () => {
onAddComment={handleAddComment}
currentUser={currentUser}
/>
-
- {/* Left Panel (Users, Media & ECG) */}
- {leftPanelVisible && (
-
-
+
+ {/* Custom Popup */}
+ {popupMessage && (
+
)}
-
- {/* Disconnect Toast */}
+
+ {/* 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 */}
@@ -733,13 +850,13 @@ const Room = () => {
{
onClick={() => window.location.reload()}
className="ml-2 bg-primary/10 hover:bg-primary/20 text-primary rounded p-1 transition-colors"
title="Refresh to reconnect"
- style={{ display: 'flex', alignItems: 'center' }}
+ style={{ display: "flex", alignItems: "center" }}
>
diff --git a/client/components/CommentsPanel.tsx b/client/components/CommentsPanel.tsx
index 8bb50f9..005ee31 100644
--- a/client/components/CommentsPanel.tsx
+++ b/client/components/CommentsPanel.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -45,6 +45,9 @@ export const CommentsPanel: React.FC
= ({
}) => {
const [newComment, setNewComment] = useState('');
const [selectedLine, setSelectedLine] = useState(null);
+ const [scrollState, setScrollState] = useState({ top: false, bottom: false });
+
+ const commentsScrollRef = useRef(null);
// Update selected line when editor selection changes
useEffect(() => {
@@ -59,6 +62,36 @@ export const CommentsPanel: React.FC = ({
}
}, [selectedLineStart, selectedLineEnd]);
+ // Scroll detection function
+ const handleScroll = () => {
+ const element = commentsScrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ const isScrolledFromTop = scrollTop > 5;
+ const isScrolledFromBottom = scrollTop < scrollHeight - clientHeight - 5;
+
+ setScrollState({
+ top: isScrolledFromTop,
+ bottom: isScrolledFromBottom && scrollHeight > clientHeight
+ });
+ };
+
+ // Add scroll listener
+ useEffect(() => {
+ const element = commentsScrollRef.current;
+
+ if (element) {
+ element.addEventListener('scroll', handleScroll);
+ // Initial check
+ handleScroll();
+
+ return () => {
+ element.removeEventListener('scroll', handleScroll);
+ };
+ }
+ }, [comments]);
+
const handleAddComment = () => {
if (newComment.trim() && onAddComment && currentUser) {
const lineRange = selectedLineStart && selectedLineEnd && selectedLineStart !== selectedLineEnd
@@ -74,14 +107,19 @@ export const CommentsPanel: React.FC = ({
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
- if (!isVisible) {
- return null;
- }
-
return (
-
+
{/* Comments List */}
-
+
{comments.length === 0 ? (
diff --git a/client/components/LeftPanel.tsx b/client/components/LeftPanel.tsx
index 7320062..4803ac1 100644
--- a/client/components/LeftPanel.tsx
+++ b/client/components/LeftPanel.tsx
@@ -39,7 +39,6 @@ interface MediaFile {
interface LeftPanelProps {
isVisible: boolean;
- isConnected: boolean;
className?: string;
users?: ActiveUser[];
mediaFiles?: MediaFile[];
@@ -50,7 +49,6 @@ interface LeftPanelProps {
export const LeftPanel: React.FC
= ({
isVisible,
- className = '',
users = [],
mediaFiles = [],
onFileDelete,
@@ -58,12 +56,57 @@ export const LeftPanel: React.FC = ({
}) => {
const [activeUsers, setActiveUsers] = useState(users);
const [localMediaFiles, setLocalMediaFiles] = useState(mediaFiles);
+ const [usersScrollState, setUsersScrollState] = useState({ top: false, bottom: false });
+ const [mediaScrollState, setMediaScrollState] = useState({ top: false, bottom: false });
+
+ const usersScrollRef = useRef(null);
+ const mediaScrollRef = useRef(null);
// Update local state when props change
useEffect(() => {
setActiveUsers(users);
}, [users]);
+ // Scroll detection function
+ const handleScroll = (element: HTMLDivElement | null, setState: (state: { top: boolean; bottom: boolean }) => void) => {
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ const isScrolledFromTop = scrollTop > 5;
+ const isScrolledFromBottom = scrollTop < scrollHeight - clientHeight - 5;
+
+ setState({
+ top: isScrolledFromTop,
+ bottom: isScrolledFromBottom && scrollHeight > clientHeight
+ });
+ };
+
+ // Add scroll listeners
+ useEffect(() => {
+ const usersElement = usersScrollRef.current;
+ const mediaElement = mediaScrollRef.current;
+
+ const handleUsersScroll = () => handleScroll(usersElement, setUsersScrollState);
+ const handleMediaScroll = () => handleScroll(mediaElement, setMediaScrollState);
+
+ if (usersElement) {
+ usersElement.addEventListener('scroll', handleUsersScroll);
+ // Initial check
+ handleUsersScroll();
+ }
+
+ if (mediaElement) {
+ mediaElement.addEventListener('scroll', handleMediaScroll);
+ // Initial check
+ handleMediaScroll();
+ }
+
+ return () => {
+ if (usersElement) usersElement.removeEventListener('scroll', handleUsersScroll);
+ if (mediaElement) mediaElement.removeEventListener('scroll', handleMediaScroll);
+ };
+ }, [activeUsers, localMediaFiles]);
+
useEffect(() => {
setLocalMediaFiles(mediaFiles);
}, [mediaFiles]);
@@ -195,15 +238,27 @@ export const LeftPanel: React.FC = ({
document.body.removeChild(link);
};
- if (!isVisible) {
- return null;
- }
-
return (
-
+
{/* Users Panel */}
-
-
+
+
+
+
+ Users
+
+
+
+
{activeUsers.length === 0 ? (
@@ -282,9 +337,10 @@ export const LeftPanel: React.FC
= ({
);
})
)}
-
-
-
+
+
+
+
{activeUsers.filter(u => getStatusIndicator(u).status === 'online').length} online
@@ -296,8 +352,20 @@ export const LeftPanel: React.FC = ({
{/* Media Panel */}
-
-
+
+
+
+
+ Media
+
+
+
+
{localMediaFiles.length === 0 ? (
@@ -396,9 +464,10 @@ export const LeftPanel: React.FC
= ({
))
)}
-
-
-
+
+
+
+
{localMediaFiles.length} files
{formatFileSize(localMediaFiles.reduce((total, file) => total + file.size, 0))} total
diff --git a/client/components/RightPanel.tsx b/client/components/RightPanel.tsx
new file mode 100644
index 0000000..7264b90
--- /dev/null
+++ b/client/components/RightPanel.tsx
@@ -0,0 +1,217 @@
+import React, { useState, useEffect, useRef } 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);
+ const [scrollState, setScrollState] = useState({ top: false, bottom: false });
+
+ const commentsScrollRef = useRef(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]);
+
+ // Scroll detection function
+ const handleScroll = () => {
+ const element = commentsScrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ const isScrolledFromTop = scrollTop > 5;
+ const isScrolledFromBottom = scrollTop < scrollHeight - clientHeight - 5;
+
+ setScrollState({
+ top: isScrolledFromTop,
+ bottom: isScrolledFromBottom && scrollHeight > clientHeight
+ });
+ };
+
+ // Add scroll listener
+ useEffect(() => {
+ const element = commentsScrollRef.current;
+
+ if (element) {
+ element.addEventListener('scroll', handleScroll);
+ // Initial check
+ handleScroll();
+
+ return () => {
+ element.removeEventListener('scroll', handleScroll);
+ };
+ }
+ }, [comments]);
+
+ 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' });
+ };
+
+ 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 */}
+
+
+ );
+};
\ No newline at end of file
diff --git a/client/lib/themes.ts b/client/lib/themes.ts
index 95c7ac2..38de91f 100644
--- a/client/lib/themes.ts
+++ b/client/lib/themes.ts
@@ -419,11 +419,11 @@ export const getNextTheme = (currentThemeId: string): ThemeConfig => {
// Cookie utilities
export const saveThemeToCookie = (themeId: string): void => {
- document.cookie = `vscode-theme=${themeId}; path=/; max-age=${60 * 60 * 24 * 365}`; // 1 year
+ document.cookie = `theme=${themeId}; path=/; max-age=${60 * 60 * 24 * 365}`; // 1 year
};
export const getThemeFromCookie = (): string | null => {
- const match = document.cookie.match(/(?:^|; )vscode-theme=([^;]*)/);
+ const match = document.cookie.match(/(?:^|; )theme=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
};
diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts
index d6a0bc1..0a22b7d 100644
--- a/client/tailwind.config.ts
+++ b/client/tailwind.config.ts
@@ -80,6 +80,7 @@ const config = {
fontFamily: {
"jetbrains-mono": ["JetBrains Mono", "monospace"],
roboto: ["Roboto", "sans-serif"],
+ "bitcount-grid": ["var(--font-bitcount-grid)", "monospace"],
},
borderRadius: {
lg: "var(--radius)",