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 ( -
- -
-
- Room Logo +
+ + {/* 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 ( -
-
-
- -
-
- - - or - - -
-
-
- ); -}; - 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 && ( +
+
+ {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 */} +
+
+