mirror of
https://github.com/arkorty/Osborne.git
synced 2026-03-18 00:57:14 +00:00
init
This commit is contained in:
38
client/app/api/socket/route.ts
Normal file
38
client/app/api/socket/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
interface ExtendedNextRequest extends NextRequest {
|
||||
socket: {
|
||||
server: {
|
||||
wss?: WebSocketServer;
|
||||
};
|
||||
on: (event: string, listener: (...args: any[]) => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: ExtendedNextRequest) {
|
||||
const { socket } = req;
|
||||
|
||||
if (!socket?.server?.wss) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
socket.server.wss = wss;
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (message) => {
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === client.OPEN) {
|
||||
client.send(message.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("upgrade", (request: any, socket: any, head: any) => {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("WebSocket setup complete");
|
||||
}
|
||||
BIN
client/app/favicon.ico
Normal file
BIN
client/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
client/app/fonts/GeistMonoVF.woff
Normal file
BIN
client/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
client/app/fonts/GeistVF.woff
Normal file
BIN
client/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
87
client/app/globals.css
Normal file
87
client/app/globals.css
Normal file
@@ -0,0 +1,87 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap");
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: rgb(251, 241, 199);
|
||||
--foreground: rgb(60, 56, 54);
|
||||
--card: rgb(235, 219, 178);
|
||||
--card-foreground: rgb(60, 56, 54);
|
||||
--popover: rgb(235, 219, 178);
|
||||
--popover-foreground: rgb(60, 56, 54);
|
||||
--primary: rgb(211, 134, 155);
|
||||
--primary-foreground: rgb(251, 241, 199);
|
||||
--secondary: rgb(184, 187, 38);
|
||||
--secondary-foreground: rgb(80, 73, 69);
|
||||
--muted: rgb(168, 153, 132);
|
||||
--muted-foreground: rgb(102, 92, 84);
|
||||
--accent: rgb(250, 189, 47);
|
||||
--accent-foreground: rgb(80, 73, 69);
|
||||
--destructive: rgb(204, 36, 29);
|
||||
--destructive-foreground: rgb(251, 241, 199);
|
||||
--border: rgb(213, 196, 161);
|
||||
--input: rgb(213, 196, 161);
|
||||
--ring: rgb(211, 134, 155);
|
||||
--radius: 0.5rem;
|
||||
--chart-1: rgb(250, 189, 47);
|
||||
--chart-2: rgb(131, 165, 152);
|
||||
--chart-3: rgb(69, 133, 136);
|
||||
--chart-4: rgb(254, 128, 25);
|
||||
--chart-5: rgb(251, 73, 52);
|
||||
--info: rgb(131, 165, 152);
|
||||
--info-foreground: rgb(40, 40, 40);
|
||||
--warning: rgb(215, 153, 33);
|
||||
--warning-foreground: rgb(40, 40, 40);
|
||||
--success: rgb(184, 187, 38);
|
||||
--success-foreground: rgb(40, 40, 40);
|
||||
--error: rgb(251, 73, 52);
|
||||
--error-foreground: rgb(40, 40, 40);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: rgb(40, 40, 40);
|
||||
--foreground: rgb(235, 219, 178);
|
||||
--card: rgb(60, 56, 54);
|
||||
--card-foreground: rgb(235, 219, 178);
|
||||
--popover: rgb(60, 56, 54);
|
||||
--popover-foreground: rgb(235, 219, 178);
|
||||
--primary: rgb(211, 134, 155);
|
||||
--primary-foreground: rgb(40, 40, 40);
|
||||
--secondary: rgb(80, 73, 69);
|
||||
--secondary-foreground: rgb(235, 219, 178);
|
||||
--muted: rgb(80, 73, 69);
|
||||
--muted-foreground: rgb(168, 153, 132);
|
||||
--accent: rgb(80, 73, 69);
|
||||
--accent-foreground: rgb(235, 219, 178);
|
||||
--destructive: rgb(204, 36, 29);
|
||||
--destructive-foreground: rgb(235, 219, 178);
|
||||
--border: rgb(102, 92, 84);
|
||||
--input: rgb(102, 92, 84);
|
||||
--ring: rgb(211, 134, 155);
|
||||
--chart-1: rgb(250, 189, 47);
|
||||
--chart-2: rgb(131, 165, 152);
|
||||
--chart-3: rgb(69, 133, 136);
|
||||
--chart-4: rgb(254, 128, 25);
|
||||
--chart-5: rgb(251, 73, 52);
|
||||
--info: rgb(131, 165, 152);
|
||||
--info-foreground: rgb(235, 219, 178);
|
||||
--warning: rgb(215, 153, 33);
|
||||
--warning-foreground: rgb(235, 219, 178);
|
||||
--success: rgb(184, 187, 38);
|
||||
--success-foreground: rgb(235, 219, 178);
|
||||
--error: rgb(251, 73, 52);
|
||||
--error-foreground: rgb(235, 219, 178);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: var(--font-roboto), sans-serif;
|
||||
}
|
||||
textarea {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
}
|
||||
}
|
||||
52
client/app/layout.tsx
Normal file
52
client/app/layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Osborne",
|
||||
description: "Real-time multi-user text editor with rooms.",
|
||||
openGraph: {
|
||||
title: "Osborne",
|
||||
description: "Real-time multi-user text editor with rooms.",
|
||||
url: "https://o.webark.in",
|
||||
siteName: "Osborne",
|
||||
images: [
|
||||
{
|
||||
url: "https://o.webark.in/og-image.png",
|
||||
width: 1500,
|
||||
height: 768,
|
||||
alt: "Osborne",
|
||||
},
|
||||
],
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
116
client/app/page.tsx
Normal file
116
client/app/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import Image from "next/image";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const Home = () => {
|
||||
const router = useRouter();
|
||||
const [newRoomCode, setNewRoomCode] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const joinRoom = () => {
|
||||
if (newRoomCode) {
|
||||
router.push(`/room?code=${newRoomCode.toUpperCase()}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (newRoomCode.length === 6) {
|
||||
joinRoom();
|
||||
}
|
||||
}, [newRoomCode, router]);
|
||||
|
||||
const createNewRoom = () => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let code = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
router.push(`/room?code=${code}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center bg-background dark:bg-background">
|
||||
<Card className="relative z-10 max-w-md backdrop-blur-sm shadow-lg bg-card/0 bg-opacity-0 dark:bg-card/70 border border-border dark:border-border p-6 flex flex-col items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className=" m-8 bg-black">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Room Logo"
|
||||
width={128}
|
||||
height={128}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="flex flex-col items-center space-y-4 font-jetbrains-mono">
|
||||
<InputOTP
|
||||
value={newRoomCode}
|
||||
onChange={(value) => setNewRoomCode(value.toUpperCase())}
|
||||
maxLength={6}
|
||||
pattern="[A-Z0-9]*"
|
||||
inputMode="text"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className="text-foreground bg-background dark:text-foreground dark:bg-background dark:caret-foreground"
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<span className="text-lg text-foreground/70 dark:text-foreground/70">
|
||||
or
|
||||
</span>
|
||||
<Button
|
||||
onClick={createNewRoom}
|
||||
variant="default"
|
||||
className="w-min bg-primary text-primary-foreground text-lg font-semibold hover:bg-primary/80"
|
||||
>
|
||||
Create Room
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonHome = () => {
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center bg-background dark:bg-background">
|
||||
<div className="relative z-10 max-w-md backdrop-blur-sm shadow-lg bg-card/0 bg-opacity-0 dark:bg-card/70 border border-border dark:border-border p-6 flex flex-col items-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Skeleton className="w-[128px] h-[128px] bg-black m-8" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Skeleton className="w-full h-12 bg-background dark:bg-background" />
|
||||
<span className="text-lg text-foreground/70 dark:text-foreground/70">
|
||||
or
|
||||
</span>
|
||||
<Skeleton className="w-32 h-12 bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HomeWrapper = () => (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<Suspense fallback={<SkeletonHome />}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
export default HomeWrapper;
|
||||
800
client/app/room/page.tsx
Normal file
800
client/app/room/page.tsx
Normal file
@@ -0,0 +1,800 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, Suspense, useMemo } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Link2, LogOut, Sun, Moon, Upload, WifiOff, RefreshCw } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CommentsPanel } from "@/components/CommentsPanel";
|
||||
import { CodeEditor, CodeEditorRef } from "@/components/CodeEditor";
|
||||
import { LeftPanel } from "@/components/LeftPanel";
|
||||
import {
|
||||
getThemeById,
|
||||
getNextTheme,
|
||||
saveThemeToCookie,
|
||||
getThemeFromCookie,
|
||||
applyTheme
|
||||
} from "@/lib/themes";
|
||||
import debounce from "lodash/debounce";
|
||||
import dotenv from "dotenv";
|
||||
import { JetBrains_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
weight: ["400", "500", "700"],
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
});
|
||||
|
||||
interface TextUpdate {
|
||||
type: "text-update";
|
||||
content: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface InitialContent {
|
||||
type: "initial-content";
|
||||
content: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface JoinRoom {
|
||||
type: "join-room";
|
||||
code: string;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
interface PingMessage {
|
||||
type: "ping";
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface PongMessage {
|
||||
type: "pong";
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
lastSeen: Date;
|
||||
isTyping?: boolean;
|
||||
currentLine?: number;
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
lineNumber: number | null;
|
||||
lineRange?: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface CommentMessage {
|
||||
type: "comment-add" | "comment-update" | "comment-delete";
|
||||
code: string;
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
interface CommentsSync {
|
||||
type: "comments-sync";
|
||||
code: string;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
interface UserMessage {
|
||||
type: "user-joined" | "user-left";
|
||||
code: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface UsersSync {
|
||||
type: "users-sync";
|
||||
code: string;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
interface UserActivity {
|
||||
type: "user-activity";
|
||||
code: string;
|
||||
userId: string;
|
||||
isTyping: boolean;
|
||||
currentLine?: number;
|
||||
}
|
||||
|
||||
interface MediaFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
url: string;
|
||||
uploadedAt: Date;
|
||||
uploadedBy: string;
|
||||
}
|
||||
|
||||
interface MediaMessage {
|
||||
type: "media-upload" | "media-delete";
|
||||
code: string;
|
||||
media: MediaFile;
|
||||
}
|
||||
|
||||
interface MediaSync {
|
||||
type: "media-sync";
|
||||
code: string;
|
||||
mediaFiles: MediaFile[];
|
||||
}
|
||||
|
||||
type Message =
|
||||
| TextUpdate
|
||||
| InitialContent
|
||||
| JoinRoom
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| CommentMessage
|
||||
| CommentsSync
|
||||
| UserMessage
|
||||
| UsersSync
|
||||
| UserActivity
|
||||
| MediaMessage
|
||||
| MediaSync;
|
||||
|
||||
const WS_URL = `${process.env.NEXT_PUBLIC_WS_URL}`;
|
||||
|
||||
// Utility functions
|
||||
const generateUserId = () => `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const generateUserName = () => {
|
||||
const adjectives = ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Brown"];
|
||||
const nouns = ["Cat", "Dog", "Bird", "Fish", "Bear", "Lion", "Tiger", "Wolf"];
|
||||
return `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||
};
|
||||
|
||||
const generateUserColor = () => {
|
||||
const colors = ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e67e22", "#34495e"];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
|
||||
const Room = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const roomCode = searchParams.get("code");
|
||||
|
||||
const socketRef = useRef<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-sm text-foreground dark:text-background bg-chart-1 hover:bg-chart-1/80 font-bold"
|
||||
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-primary w-10 hover:bg-primary/80 p-1"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert("Room link copied to clipboard!");
|
||||
}}
|
||||
>
|
||||
<Link2
|
||||
size={16}
|
||||
className="text-foreground dark:text-background"
|
||||
/>
|
||||
</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 p-1"
|
||||
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="text-sm w-10 bg-chart-1 hover:bg-chart-1/80 dark:text-foreground font-medium"
|
||||
onClick={() => {
|
||||
console.log('Upload button clicked');
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload size={16} />
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="py-1 px-2 w-auto text-xs border-foreground">
|
||||
upload files
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Button
|
||||
className="text-sm w-10 bg-chart-3 hover:bg-chart-3/80 dark:text-foreground font-medium"
|
||||
onClick={() => {
|
||||
const nextTheme = getNextTheme(currentThemeId);
|
||||
setCurrentThemeId(nextTheme.id);
|
||||
applyTheme(nextTheme);
|
||||
saveThemeToCookie(nextTheme.id);
|
||||
}}
|
||||
>
|
||||
{getThemeById(currentThemeId)?.type === "dark" ? (
|
||||
<Sun size={16} />
|
||||
) : (
|
||||
<Moon size={16} />
|
||||
)}
|
||||
</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;
|
||||
Reference in New Issue
Block a user