Files
Osborne/client/app/room/page.tsx
2025-11-04 05:02:27 +05:30

1346 lines
41 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 {
Card,
CardContent,
CardHeader,
CardTitle,
CardFooter,
} from "@/components/ui/card";
import {
WifiOff,
RefreshCw,
TriangleAlert,
} from "lucide-react";
import { CommentsPanel } from "@/components/RightPanel";
import { CodeEditor, CodeEditorRef } from "@/components/Editor";
import { LeftPanel } from "@/components/LeftPanel";
import RecordingPopup from "@/components/RecordingPopup";
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";
import { ContentWarningModal } from "@/components/ContentWarningModal";
import { BetterHoverCard, HoverCardProvider } from "@/components/ui/BetterHoverCard";
import { getCookie, setCookie } from "@/lib/cookies";
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)];
};
// User persistence helpers using cookies
const restoreUser = (): User | null => {
if (typeof window === 'undefined') return null;
try {
const stored = getCookie("osborne-user");
if (stored) {
const parsed = JSON.parse(stored);
return {
...parsed,
lastSeen: new Date(parsed.lastSeen),
};
}
} catch (error) {
console.error('Error loading user from storage:', error);
}
return null;
};
const saveUser = (user: User) => {
if (typeof window === 'undefined') return;
try {
setCookie("osborne-user", JSON.stringify(user), 30); // 30 days
} catch (error) {
console.error('Error saving user to storage:', error);
}
};
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 currentUserRef = useRef<User | null>(null);
const [isClient, setIsClient] = useState(false);
const [content, setContent] = useState("");
const [status, setStatus] = useState("Disconnected");
const [error, setError] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [isPurgeModalOpen, setIsPurgeModalOpen] = useState(false);
const [showReconnectOverlay, setShowReconnectOverlay] = useState(false);
const [currentThemeId, setCurrentThemeId] = useState("one-dark");
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 [windowWidth, setWindowWidth] = useState(0);
const [leftPanelForced, setLeftPanelForced] = useState(false);
const [rightPanelForced, setRightPanelForced] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [fileSizeError, setFileSizeError] = useState<string | null>(null);
const [purgeError, setPurgeError] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<Array<{fileName: string; progress: number; status: 'uploading' | 'completed' | 'error'}>>([]);
const [isRecordingOpen, setIsRecordingOpen] = useState(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [isReconnecting, setIsReconnecting] = useState(false);
const [gotClicked, click] = useState(false);
// Detect mobile screen size
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768); // md breakpoint
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Mobile swipe gesture handling & Escape key to close panels
useEffect(() => {
// Swipe gesture (mobile only)
if (isMobile) {
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchStartTime = Date.now();
};
const handleTouchEnd = (e: TouchEvent) => {
const touch = e.changedTouches[0];
const touchEndX = touch.clientX;
const touchEndY = touch.clientY;
const touchEndTime = Date.now();
const deltaX = touchEndX - touchStartX;
const deltaY = touchEndY - touchStartY;
const deltaTime = touchEndTime - touchStartTime;
// Only consider it a swipe if:
// 1. The gesture is fast enough (less than 500ms)
// 2. The horizontal distance is significant (at least 100px)
// 3. The vertical distance is less than horizontal (to avoid conflicting with scrolling)
if (
deltaTime < 500 &&
Math.abs(deltaX) > 100 &&
Math.abs(deltaX) > Math.abs(deltaY)
) {
if (deltaX < 0 && leftPanelForced) {
// Swipe left - close left panel
setLeftPanelForced(false);
} else if (deltaX > 0 && rightPanelForced) {
// Swipe right - close right panel
setRightPanelForced(false);
}
}
};
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
// Clean up swipe listeners
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchend', handleTouchEnd);
};
}
// Escape key closes panels (all devices)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (leftPanelForced) setLeftPanelForced(false);
if (rightPanelForced) setRightPanelForced(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isMobile, leftPanelForced, rightPanelForced]);
const contentRef = useRef(content);
useEffect(() => {
contentRef.current = content;
}, [content]);
useEffect(() => {
currentUserRef.current = currentUser;
}, [currentUser]);
useEffect(() => {
setIsClient(true);
// Load user from storage if available
if (roomCode) {
const storedUser = restoreUser();
if (storedUser) {
setCurrentUser(storedUser);
}
}
// 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);
};
}, [roomCode]);
// Calculate panel visibility based on window width
// Minimum width needed: 320px (left) + 640px (main content) + 320px (right) = 1280px
const showSidePanels = windowWidth >= 1280;
// Auto-hide forced panels when screen size increases (do this before calculating visibility)
useEffect(() => {
if (showSidePanels) {
setLeftPanelForced(false);
setRightPanelForced(false);
}
}, [showSidePanels]);
// Calculate final panel visibility - when shouldShowPanels is true, always show panels regardless of forced state
const showLeftPanel = showSidePanels || (!showSidePanels && leftPanelForced);
const showRightPanel = showSidePanels || (!showSidePanels && rightPanelForced);
// Initialize theme from cookie
useEffect(() => {
if (isClient) {
const savedThemeId = getThemeFromCookie();
if (savedThemeId) {
const savedTheme = getThemeById(savedThemeId);
if (savedTheme) {
setCurrentThemeId(savedThemeId);
applyTheme(savedTheme);
}
} else {
// Apply default theme
const defaultTheme = getThemeById("one-dark-pro");
if (defaultTheme) {
applyTheme(defaultTheme);
}
}
}
}, [isClient]);
// Apply theme when currentThemeId changes
useEffect(() => {
const currentTheme = getThemeById(currentThemeId);
if (currentTheme) {
applyTheme(currentTheme);
}
}, [currentThemeId]);
// Show reconnect overlay only if still disconnected after a delay
useEffect(() => {
let showTimer: NodeJS.Timeout | null = null;
if (status === "Disconnected") {
// Wait 800ms before showing overlay
showTimer = setTimeout(() => {
setShowReconnectOverlay(true);
}, 800);
} else {
setShowReconnectOverlay(false);
}
return () => {
if (showTimer) clearTimeout(showTimer);
};
}, [status]);
// Calculate panel visibility based on viewport width
// Left panel (256px) + Right panel (320px) + Main content (1280px) + padding (~100px) = ~1956px
const debouncedSend = useMemo(
() =>
debounce((ws: WebSocket, content: string, code: string) => {
if (ws.readyState === WebSocket.OPEN) {
const message: TextUpdate = {
type: "text-update",
content,
code,
};
ws.send(JSON.stringify(message));
}
}, 100),
[]
);
const connectSocket = useCallback(() => {
if (!roomCode || socketRef.current?.readyState === WebSocket.OPEN) return;
if (socketRef.current) {
socketRef.current.close();
}
const ws = new WebSocket(WS_URL);
socketRef.current = ws;
ws.onopen = () => {
setStatus("Connected");
setError("");
setIsReconnecting(false);
setReconnectAttempts(0); // Reset on successful connection
// Use existing user, check storage, or create new one
let user = currentUserRef.current;
if (!user && roomCode) {
user = restoreUser();
}
if (!user) {
user = {
id: generateUserId(),
name: generateUserName(),
color: generateUserColor(),
lastSeen: new Date(),
isTyping: false,
};
}
// Update lastSeen for reconnection
user.lastSeen = new Date();
setCurrentUser(user);
// Save to storage for persistence across sessions
saveUser(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");
setIsReconnecting(false);
setTimeout(() => {
if (socketRef.current === ws) {
socketRef.current = null;
}
}, 0);
};
ws.onerror = () => {
setIsReconnecting(true);
const backoffDelay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Max 10 seconds
setTimeout(() => {
if (socketRef.current === ws) {
setReconnectAttempts(prev => prev + 1);
connectSocket();
}
}, backoffDelay);
};
}, [roomCode, reconnectAttempts]);
useEffect(() => {
if (!isClient || !roomCode) return;
connectSocket();
const pingInterval = setInterval(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "ping", code: roomCode }));
// Also send user activity update to keep status current
if (currentUserRef.current) {
const activityMessage: UserActivity = {
type: "user-activity",
code: roomCode,
userId: currentUserRef.current.id,
isTyping: false,
currentLine: undefined
};
socketRef.current.send(JSON.stringify(activityMessage));
}
}
}, 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 handleDeleteComment = (commentId: string) => {
if (!socketRef.current || !currentUser) return;
const message: CommentMessage = {
type: "comment-delete",
code: roomCode!,
comment: {
id: commentId,
lineNumber: null,
author: "",
authorId: "",
content: "",
timestamp: new Date(),
},
};
socketRef.current.send(JSON.stringify(message));
};
const handlePurgeRoom = async () => {
if (!roomCode) return;
try {
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090";
const response = await fetch(`${httpUrl}/purge/${roomCode}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(`Purge failed: ${response.statusText}`);
}
router.push("/");
} catch (error) {
console.error("Error purging room:", error);
setPurgeError("Failed to purge room");
}
setIsPurgeModalOpen(false);
};
const handleFileUpload = async (files: FileList) => {
if (!files || files.length === 0 || !currentUser) return;
const maxFileSize = 10 * 1024 * 1024; // 10MB in bytes
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090";
// Initialize progress for all files
const initialProgress = Array.from(files).map(file => ({
fileName: file.name,
progress: 0,
status: 'uploading' as const
}));
setUploadProgress(initialProgress);
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check file size limit
if (file.size > maxFileSize) {
const fileSizeInMB = (file.size / (1024 * 1024)).toFixed(2);
setFileSizeError(`File "${file.name}" (${fileSizeInMB}MB) exceeds 10MB limit`);
setUploadProgress(prev => prev.map(p =>
p.fileName === file.name ? { ...p, status: 'error' as const } : p
));
continue; // Skip this file and continue with others
}
try {
// Create form data for file upload
const formData = new FormData();
formData.append("file", file);
formData.append("roomCode", roomCode!);
formData.append("uploadedBy", currentUser.name);
// Use XMLHttpRequest for progress tracking
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100);
setUploadProgress(prev => prev.map(p =>
p.fileName === file.name ? { ...p, progress } : p
));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
setUploadProgress(prev => prev.map(p =>
p.fileName === file.name ? { ...p, progress: 100, status: 'completed' as const } : p
));
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
xhr.open('POST', `${httpUrl}/upload`);
xhr.send(formData);
});
console.log("File uploaded successfully:", file.name);
} catch (error) {
console.error("Error uploading file:", error);
setUploadProgress(prev => prev.map(p =>
p.fileName === file.name ? { ...p, status: 'error' as const } : p
));
}
}
// Clear progress after a delay
setTimeout(() => {
setUploadProgress([]);
}, 3000);
};
const handleFileDelete = async (fileId: string) => {
if (!roomCode) return;
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8090";
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 (
<HoverCardProvider>
<div className="relative min-h-dvh bg-background dark:bg-background ui-font">
<div
className="absolute inset-0 transition-all duration-300"
style={{
left: isMobile ? '0px' : (showLeftPanel ? '320px' : '0px'),
right: isMobile ? '0px' : (showRightPanel ? '320px' : '0px'),
}}
>
<div
className={`flex flex-col items-center relative z-10 w-full h-full bg-card dark:bg-card shadow-md transition-all duration-300 ${
isModalOpen || isPurgeModalOpen ? "blur-sm" : ""
}`}
>
<div className="flex flex-row items-center justify-between p-1 w-full">
<div className="flex gap-1 mr-1">
<BetterHoverCard
trigger={
<Button
variant="default"
className={`text-foreground min-w-16 px-2 py-0 h-5 rounded-sm text-xs btn-micro transition-colors duration-200 ${
gotClicked
? 'bg-warning'
: 'bg-secondary hover:bg-secondary/80'
}`}
onClick={() => {
navigator.clipboard.writeText(window.location.href);
click(true);
setTimeout(() => click(false), 2000);
}}
>
{gotClicked ? 'copied!' : 'share'}
</Button>
}
contentClassName="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground"
>
copy link to clipboard
</BetterHoverCard>
<BetterHoverCard
trigger={
<Button
className="bg-destructive px-2 py-0 h-5 text-xs rounded-sm hover:bg-destructive/80 btn-micro"
variant="destructive"
onClick={() => setIsPurgeModalOpen(true)}
>
purge
</Button>
}
contentClassName="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground"
>
permanently delete this room
</BetterHoverCard>
<BetterHoverCard
trigger={
<Button
className="bg-destructive px-2 py-0 h-5 text-xs rounded-sm hover:bg-destructive/80 btn-micro"
variant="destructive"
onClick={() => router.push("/")}
>
exit
</Button>
}
contentClassName="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground"
>
return to home
</BetterHoverCard>
</div>
<div className="flex gap-1 overflow-x-auto scrollbar-hide">
<BetterHoverCard
trigger={
<Button
className="bg-chart-2 px-2 py-0 h-5 text-xs rounded-sm hover:bg-chart-2/80 btn-micro"
onClick={() => {
console.log("Upload button clicked");
fileInputRef.current?.click();
}}
>
upload
</Button>
}
contentClassName="py-1 px-2 w-auto text-xs border-foreground"
>
upload files
</BetterHoverCard>
<BetterHoverCard
trigger={
<Button
className="bg-chart-4 px-2 py-0 h-5 text-xs rounded-sm hover:bg-chart-4/80 btn-micro"
onClick={() => {
const nextTheme = getNextTheme(currentThemeId);
setCurrentThemeId(nextTheme.id);
applyTheme(nextTheme);
saveThemeToCookie(nextTheme.id);
}}
>
theme
</Button>
}
contentClassName="py-1 px-2 w-auto text-xs border-foreground"
>
{`switch to ${getThemeById(getNextTheme(currentThemeId)?.id)?.name}`}
</BetterHoverCard>
<BetterHoverCard
trigger={
<Button
className="bg-red-500 px-2 py-0 h-5 text-xs rounded-sm btn-micro"
onClick={() => setIsRecordingOpen(true)}
>
record
</Button>
}
contentClassName="py-1 px-2 w-auto text-xs border-foreground"
>
record audio
</BetterHoverCard>
{/* Panel Controls for mobile and when panels are hidden due to width */}
{(isMobile || !showSidePanels) && (
<>
<BetterHoverCard
trigger={
<Button
className="bg-chart-1 px-2 py-0 h-5 text-xs rounded-sm hover:bg-chart-1/80 btn-micro"
onClick={() => setLeftPanelForced(!leftPanelForced)}
>
media
</Button>
}
contentClassName="py-1 px-2 w-auto text-xs border-foreground z-[999]"
>
show media
</BetterHoverCard>
<BetterHoverCard
trigger={
<Button
className="bg-chart-3 px-2 py-0 h-5 text-xs rounded-sm hover:bg-chart-3/80 btn-micro"
onClick={() => setRightPanelForced(!rightPanelForced)}
>
notes
</Button>
}
contentClassName="py-1 px-2 w-auto text-xs border-foreground"
>
show comments
</BetterHoverCard>
</>
)}
</div>
</div>
<div className="flex-grow flex flex-col p-1 w-full">
{error && status !== "Connected" && (
<div className="mb-2 p-2 bg-destructive/10 text-destructive rounded text-sm">
{error}
</div>
)}
{isMobile ? (
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
className="flex-grow w-full p-3 bg-background text-foreground border border-border rounded resize-none font-mono text-sm focus:outline-none focus:border-primary"
placeholder="Start typing..."
style={{ fontFamily: 'JetBrains Mono, Consolas, Monaco, "Courier New", monospace' }}
/>
) : (
<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={isMobile ? rightPanelForced : showRightPanel}
onToggle={() => setRightPanelForced(!rightPanelForced)}
selectedLineStart={selectedLineStart}
selectedLineEnd={selectedLineEnd}
onCommentSelect={handleCommentSelect}
comments={comments}
onAddComment={handleAddComment}
onDeleteComment={handleDeleteComment}
currentUser={currentUser}
/>
{/* File Size Error Modal */}
{fileSizeError && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setFileSizeError(null)}>
<Card className="max-w-md text-center animate-in fade-in slide-in-from-bottom-4 duration-300" onClick={(e) => e.stopPropagation()}>
<CardHeader>
<TriangleAlert className="mx-auto mb-2 text-warning" size={48} />
<CardTitle className="text-lg text-warning">
File Too Large
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">
{fileSizeError}
</p>
<Button
onClick={() => setFileSizeError(null)}
className="w-full"
>
OK
</Button>
</CardContent>
</Card>
</div>
)}
{/* Purge Error Modal */}
{purgeError && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setPurgeError(null)}>
<Card className="max-w-md text-center animate-in fade-in slide-in-from-bottom-4 duration-300" onClick={(e) => e.stopPropagation()}>
<CardHeader>
<TriangleAlert className="mx-auto mb-2 text-destructive" size={48} />
<CardTitle className="text-lg text-destructive">
Error
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">
{purgeError}
</p>
<Button
onClick={() => setPurgeError(null)}
className="w-full"
>
OK
</Button>
</CardContent>
</Card>
</div>
)}
{/* Upload Progress Overlay */}
{uploadProgress.length > 0 && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<Card className="max-w-md animate-in fade-in slide-in-from-bottom-4 duration-300">
<CardHeader>
<CardTitle className="text-lg text-center">
Uploading Files
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{uploadProgress.map((upload, index) => (
<div key={index} className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="truncate max-w-[200px]" title={upload.fileName}>
{upload.fileName}
</span>
<span className={`text-xs ${
upload.status === 'completed' ? 'text-green-600' :
upload.status === 'error' ? 'text-destructive' :
'text-muted-foreground'
}`}>
{upload.status === 'completed' ? '✓' :
upload.status === 'error' ? '✗' :
`${upload.progress}%`}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
upload.status === 'completed' ? 'bg-green-600' :
upload.status === 'error' ? 'bg-destructive' :
'bg-primary'
}`}
style={{ width: `${upload.progress}%` }}
/>
</div>
</div>
))}
</CardContent>
</Card>
</div>
)}
{/* Overlay for mobile when panels are forced open */}
{!showSidePanels && (leftPanelForced || rightPanelForced) && (
<div
className="fixed inset-0 bg-black/20 z-30"
onClick={() => {
setLeftPanelForced(false);
setRightPanelForced(false);
}}
/>
)}
{/* Left Panel (Users, Media & ECG) */}
<LeftPanel
isVisible={isMobile ? leftPanelForced : showLeftPanel}
users={users}
currentUser={currentUser}
mediaFiles={mediaFiles}
onFileUpload={handleFileUpload}
onFileDelete={handleFileDelete}
onModalStateChange={setIsModalOpen}
/>
{/* Purge Confirmation Modal */}
{isPurgeModalOpen && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" onClick={() => setIsPurgeModalOpen(false)}>
<Card className="max-w-md animate-in fade-in slide-in-from-bottom-4 duration-300" onClick={(e) => e.stopPropagation()}>
<CardHeader>
<CardTitle>Purge Room</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Are you sure you want to permanently delete this room and all its contents?
This action cannot be undone and will remove:
</p>
<ul className="text-sm text-muted-foreground mb-4 list-disc list-inside space-y-1">
<li>All code content</li>
<li>All comments</li>
<li>All uploaded files</li>
<li>Room history</li>
</ul>
</CardContent>
<CardFooter className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setIsPurgeModalOpen(false)}
className="text-sm text-foreground"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handlePurgeRoom}
className="text-sm"
>
Purge Room
</Button>
</CardFooter>
</Card>
</div>
)}
{/* Reconnect Overlay */}
{showReconnectOverlay && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<Card className="max-w-md text-center animate-in fade-in slide-in-from-bottom-4 duration-300">
<CardHeader>
<WifiOff className="mx-auto mb-2 text-destructive" size={48} />
<CardTitle className="text-lg text-destructive">
Connection Lost
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">
{isReconnecting
? `Attempting to reconnect... (Attempt ${reconnectAttempts + 1})`
: "The connection to the server was lost. Please check your internet connection and try to reconnect."
}
</p>
<Button
onClick={() => {
setShowReconnectOverlay(false);
setReconnectAttempts(0);
setIsReconnecting(false);
connectSocket();
}}
className="w-full"
disabled={isReconnecting}
>
<RefreshCw className={`mr-2 h-4 w-4 ${isReconnecting ? 'animate-spin' : ''}`} />
{isReconnecting ? 'Reconnecting...' : 'Reconnect'}
</Button>
{reconnectAttempts > 3 && (
<Button
onClick={() => window.location.reload()}
variant="outline"
className="w-full"
>
Force Reload
</Button>
)}
</CardContent>
</Card>
</div>
)}
{/* Content Warning Modal */}
<ContentWarningModal />
{/* Recording Popup */}
<RecordingPopup
isOpen={isRecordingOpen}
onClose={() => setIsRecordingOpen(false)}
onFileUpload={handleFileUpload}
/>
</div>
</HoverCardProvider>
);
};
const LoadingOverlay = () => (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<Card className="max-w-xs animate-in fade-in slide-in-from-bottom-4 duration-300">
<CardContent className="flex flex-col items-center justify-center p-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</div>
);
const RoomWrapper = () => (
<ThemeProvider attribute="class" defaultTheme="dark">
<Suspense fallback={<LoadingOverlay />}>
<div className={`${jetbrainsMono.variable} font-sans`}>
<Room />
</div>
</Suspense>
</ThemeProvider>
);
export default RoomWrapper;