Files
Osborne/client/app/room/page.tsx
Arkaprabha Chakraborty 125887e5aa style
2025-10-30 11:29:34 +05:30

801 lines
24 KiB
TypeScript

"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<WebSocket | null>(null);
const editorRef = useRef<CodeEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(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<number>();
const [selectedLineEnd, setSelectedLineEnd] = useState<number>();
const [comments, setComments] = useState<Comment[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(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 (
<div className="relative min-h-screen bg-background dark:bg-background">
<div className="flex justify-center">
<div className={`flex flex-col items-center p-1 relative z-10 w-full min-h-screen max-w-5xl bg-card dark:bg-card shadow-md transition-all duration-300 ${isModalOpen ? 'blur-sm' : ''}`}>
<div className="flex flex-row items-center justify-between p-2 w-full">
<div className="flex gap-2">
<HoverCard>
<HoverCardTrigger>
<Button
className="text-foreground bg-secondary"
onClick={() => {
navigator.clipboard.writeText(roomCode);
alert("Room code copied to clipboard!");
}}
>
{roomCode}
</Button>
</HoverCardTrigger>
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
copy room code
</HoverCardContent>
</HoverCard>
<HoverCard>
<HoverCardTrigger>
<Button
variant="default"
className="bg-secondary w-10 hover:bg-secondary/80"
onClick={() => {
navigator.clipboard.writeText(window.location.href);
alert("Room link copied to clipboard!");
}}
>
<Link2
size={16}
className="text-foreground"
/>
</Button>
</HoverCardTrigger>
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
copy link to this page
</HoverCardContent>
</HoverCard>
<HoverCard>
<HoverCardTrigger>
<Button
className="bg-destructive w-10 hover:bg-destructive/80"
variant="destructive"
onClick={() => router.push("/")}
>
<LogOut size={16} className="text-destructive-foreground" />
</Button>
</HoverCardTrigger>
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
return to home
</HoverCardContent>
</HoverCard>
</div>
<div className="flex gap-2">
<HoverCard>
<HoverCardTrigger>
<Button
className="w-10 bg-chart-2 hover:bg-chart-2/80"
onClick={() => {
console.log('Upload button clicked');
fileInputRef.current?.click();
}}
>
<Upload size={16} className="text-foreground" />
</Button>
</HoverCardTrigger>
<HoverCardContent className="py-1 px-2 w-auto text-xs border-foreground">
upload files
</HoverCardContent>
</HoverCard>
<HoverCard>
<HoverCardTrigger>
<Button
className="w-10 bg-chart-4 hover:bg-chart-4/80"
onClick={() => {
const nextTheme = getNextTheme(currentThemeId);
setCurrentThemeId(nextTheme.id);
applyTheme(nextTheme);
saveThemeToCookie(nextTheme.id);
}}
>
{getThemeById(currentThemeId)?.type === "dark" ? (
<Sun size={16} className="text-foreground" />
) : (
<Moon size={16} className="text-foreground" />
)}
</Button>
</HoverCardTrigger>
<HoverCardContent className="py-1 px-2 w-auto text-xs border-foreground">
{getThemeById(currentThemeId)?.name || 'Switch theme'}
</HoverCardContent>
</HoverCard>
</div>
</div>
<div className="flex-grow flex flex-col p-2 w-full">
{error && status !== "Connected" && (
<div className="mb-2 p-2 bg-destructive/10 text-destructive rounded text-sm">
{error}
</div>
)}
<CodeEditor
ref={editorRef}
value={content}
onChange={handleContentChange}
onSelectionChange={handleSelectionChange}
language="plaintext"
className="flex-grow w-full"
themeConfig={getThemeById(currentThemeId)}
/>
</div>
</div>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept="image/*,video/*,audio/*,.pdf,.txt,.json,.xml,.csv"
onChange={(e) => {
if (e.target.files) {
handleFileUpload(e.target.files);
// Reset the input so the same file can be selected again
e.target.value = '';
}
}}
/>
{/* Comments Panel */}
<CommentsPanel
isVisible={commentsVisibleResponsive}
onToggle={() => setCommentsVisible(!commentsVisible)}
selectedLineStart={selectedLineStart}
selectedLineEnd={selectedLineEnd}
onCommentSelect={handleCommentSelect}
comments={comments}
onAddComment={handleAddComment}
currentUser={currentUser}
/>
{/* Left Panel (Users, Media & ECG) */}
{leftPanelVisible && (
<div className="fixed top-4 left-4 z-40">
<LeftPanel
isVisible={leftPanelVisible}
isConnected={status === "Connected"}
users={users}
mediaFiles={mediaFiles}
onFileUpload={handleFileUpload}
onFileDelete={handleFileDelete}
onModalStateChange={setIsModalOpen}
/>
</div>
)}
{/* Disconnect Toast */}
{showDisconnectToast && (
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
{/* Blurred overlay */}
<div className="absolute inset-0 bg-background/60 backdrop-blur-sm pointer-events-auto transition-all duration-300" />
{/* Toast */}
<div
className="relative pointer-events-auto flex items-center space-x-2 px-4 py-3 rounded-lg shadow-lg border animate-in fade-in duration-300"
style={{
background: 'var(--popover, var(--card, #fff))',
color: 'var(--popover-foreground, var(--foreground, #222))',
borderColor: 'var(--border, #e5e7eb)',
borderWidth: 1,
borderStyle: 'solid',
fontWeight: 500,
width: 'auto',
minWidth: undefined,
maxWidth: undefined,
}}
>
<WifiOff size={18} className="text-destructive" />
<span className="text-sm font-medium">Connection Lost</span>
<button
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' }}
>
<RefreshCw size={15} />
</button>
</div>
</div>
)}
</div>
);
};
const SkeletonMirror = () => {
return (
<div className="relative min-h-screen">
<div className="flex flex-col items-center p-4 relative z-10">
<div className="w-full max-w-6xl bg-inherit backdrop-blur-sm bg-opacity-0">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="w-[6.3rem] h-[2.25rem] rounded bg-chart-3" />
</div>
<Skeleton className="w-20 h-6 rounded bg-chart-2" />
</div>
<div>
<Skeleton className="w-full min-h-[80vh] p-4 bg-muted border border-border" />
<div className="mt-4 flex justify-end items-center">
<div className="flex gap-2">
<Skeleton className="w-10 h-10 rounded bg-chart-1" />
<Skeleton className="w-10 h-10 rounded bg-destructive" />
</div>
</div>
</div>
</div>
</div>
</div>
);
};
const RoomWrapper = () => (
<ThemeProvider attribute="class" defaultTheme="dark">
<Suspense fallback={<SkeletonMirror />}>
<div className={`${jetbrainsMono.variable} font-sans`}>
<Room />
</div>
</Suspense>
</ThemeProvider>
);
export default RoomWrapper;