This commit is contained in:
Arkaprabha Chakraborty
2025-10-30 11:04:17 +05:30
commit 02a102481e
57 changed files with 15238 additions and 0 deletions

6
client/.eslintrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
}

40
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for commiting if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

49
client/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Room
Room is a real-time collaborative text editor built using WebSockets, designed to enable multiple users to edit text simultaneously. The application provides a seamless experience for users to collaborate and share ideas in real time.
## Features
- **Real-Time Collaboration**: Multiple users can edit the same document simultaneously, with changes reflected instantly.
- **User-Friendly Interface**: A simple and intuitive interface designed to enhance the writing experience.
- **WebSocket Integration**: Efficient real-time communication between clients and the server.
## Tech Stack
- **Frontend**: Next.js 15, TypeScript, Tailwind CSS
- **WebSocket Library**: ws
## Installation
To get started with Room, follow these steps:
1. **Clone the repository**:
```bash
git clone https://github.com/arkorty/Room.git
cd Room
```
2. **Install dependencies**:
```bash
bun install
```
3. **Run the application**:
```bash
bun run dev
```
4. **Access the application**: Open your browser and navigate to `http://localhost:3000`.
## Usage
- **Creating a Room**: Users can create a new document from the dashboard.
- **Inviting Collaborators**: Share a link with collaborators to allow them to join the editing session.
- **Editing**: Start typing in the editor; changes will be reflected in real-time for all users.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

87
client/app/globals.css Normal file
View 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
View 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
View 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
View 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;

BIN
client/bun.lockb Executable file

Binary file not shown.

20
client/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"examples": "@/components/examples",
"blocks": "@/components/blocks"
}
}

View File

@@ -0,0 +1,225 @@
import React, { useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
import Editor from '@monaco-editor/react';
import type { ThemeConfig } from '@/lib/themes';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
onSelectionChange?: (lineStart: number, lineEnd: number) => void;
language?: string;
className?: string;
themeConfig?: ThemeConfig;
}
export interface CodeEditorRef {
selectLines: (startLine: number, endLine?: number) => void;
focus: () => void;
}
export const CodeEditor = forwardRef<CodeEditorRef, CodeEditorProps>(({
value,
onChange,
onSelectionChange,
language = 'plaintext',
className = '',
themeConfig
}, ref) => {
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const [editorReady, setEditorReady] = React.useState(false);
// Expose methods to parent component
useImperativeHandle(ref, () => ({
selectLines: (startLine: number, endLine?: number) => {
if (editorRef.current) {
const actualEndLine = endLine || startLine;
const selection = {
startLineNumber: startLine,
startColumn: 1,
endLineNumber: actualEndLine,
endColumn: editorRef.current.getModel()?.getLineMaxColumn(actualEndLine) || 1,
};
editorRef.current.setSelection(selection);
editorRef.current.revealLineInCenter(startLine);
editorRef.current.focus();
}
},
focus: () => {
if (editorRef.current) {
editorRef.current.focus();
}
},
}), []);
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor;
monacoRef.current = monaco;
setEditorReady(true);
// Add selection change listener
if (onSelectionChange) {
editor.onDidChangeCursorSelection((e: any) => {
const selection = e.selection;
const startLine = selection.startLineNumber;
const endLine = selection.endLineNumber;
onSelectionChange(startLine, endLine);
});
}
};
const handleEditorChange = (value: string | undefined) => {
onChange(value || '');
};
// Ensure Monaco theme is always set after both editor and themeConfig are ready
useEffect(() => {
if (!editorReady || !themeConfig || !monacoRef.current) return;
// Convert HSL to hex for Monaco editor
const hslToHex = (hsl: string): string => {
const match = hsl.match(/hsl\((\d+),\s*(\d+)%\,\s*(\d+)%\)/);
if (!match) return '#000000';
const h = parseInt(match[1]) / 360;
const s = parseInt(match[2]) / 100;
const l = parseInt(match[3]) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
const toHex = (c: number) => {
const hex = Math.round(c * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
const defineThemeFromConfig = (config: ThemeConfig) => {
const themeName = `osborne-${config.id}`;
const backgroundColor = hslToHex(config.colors.background);
const foregroundColor = hslToHex(config.colors.foreground);
const cardColor = hslToHex(config.colors.card);
const borderColor = hslToHex(config.colors.border);
const mutedColor = hslToHex(config.colors.muted);
const primaryColor = hslToHex(config.colors.primary);
monacoRef.current.editor.defineTheme(themeName, {
base: config.type === 'dark' ? 'vs-dark' : 'vs',
inherit: true,
rules: [
{ token: '', foreground: foregroundColor.substring(1) },
{ token: 'comment', foreground: config.type === 'dark' ? '6A9955' : '008000', fontStyle: 'italic' },
{ token: 'string', foreground: config.type === 'dark' ? 'CE9178' : 'A31515' },
{ token: 'number', foreground: config.type === 'dark' ? 'B5CEA8' : '098658' },
{ token: 'keyword', foreground: primaryColor.substring(1), fontStyle: 'bold' },
{ token: 'type', foreground: config.type === 'dark' ? '4EC9B0' : '267F99' },
{ token: 'function', foreground: config.type === 'dark' ? 'DCDCAA' : '795E26' },
{ token: 'variable', foreground: config.type === 'dark' ? '9CDCFE' : '001080' },
],
colors: {
'editor.background': backgroundColor,
'editor.foreground': foregroundColor,
'editor.lineHighlightBackground': cardColor,
'editor.selectionBackground': primaryColor + '40',
'editorLineNumber.foreground': hslToHex(config.colors.mutedForeground),
'editorLineNumber.activeForeground': foregroundColor,
'editorGutter.background': backgroundColor,
'editor.inactiveSelectionBackground': mutedColor,
'editorWhitespace.foreground': borderColor,
'editorCursor.foreground': foregroundColor,
'editorIndentGuide.background': borderColor,
'editorIndentGuide.activeBackground': primaryColor,
'editor.findMatchBackground': primaryColor + '60',
'editor.findMatchHighlightBackground': primaryColor + '30',
}
});
return themeName;
};
const themeName = defineThemeFromConfig(themeConfig);
monacoRef.current.editor.setTheme(themeName);
}, [themeConfig, editorReady]);
return (
<div className={`border border-border overflow-hidden ${className}`}>
<Editor
height="100%"
language={language}
value={value}
onChange={handleEditorChange}
onMount={handleEditorDidMount}
options={{
// Line numbers
lineNumbers: 'on',
lineNumbersMinChars: 3,
// Font settings
fontFamily: 'JetBrains Mono, Consolas, Monaco, "Courier New", monospace',
fontSize: 12,
fontWeight: '400',
// Editor behavior
wordWrap: 'on',
wrappingIndent: 'indent',
automaticLayout: true,
scrollBeyondLastLine: false,
minimap: { enabled: false },
// Visual settings
renderWhitespace: 'selection',
renderLineHighlight: 'line',
cursorBlinking: 'phase',
cursorStyle: 'line',
cursorSmoothCaretAnimation: "on",
// Remove unnecessary features for a pastebin
quickSuggestions: false,
suggestOnTriggerCharacters: false,
acceptSuggestionOnEnter: 'off',
tabCompletion: 'off',
wordBasedSuggestions: 'off',
parameterHints: { enabled: false },
hover: { enabled: false },
// Disable some advanced features
folding: false,
glyphMargin: false,
contextmenu: true,
// Scrolling
smoothScrolling: true,
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10,
},
// Selection
selectOnLineNumbers: true,
selectionHighlight: false,
occurrencesHighlight: 'off',
// Placeholder-like behavior
domReadOnly: false,
readOnly: false,
}}
loading={
<div className="flex items-center justify-center h-full bg-background text-muted-foreground">
<div className="animate-pulse">Loading editor...</div>
</div>
}
/>
</div>
);
});
CodeEditor.displayName = 'CodeEditor';

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } 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<CommentsPageProps> = ({
isVisible,
selectedLineStart,
selectedLineEnd,
onCommentSelect,
comments = [],
onAddComment,
currentUser
}) => {
const [newComment, setNewComment] = useState('');
const [selectedLine, setSelectedLine] = useState<number | null>(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]);
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' });
};
if (!isVisible) {
return null;
}
return (
<div className="fixed right-0 top-0 h-full w-80 bg-card border-l border-border shadow-lg z-40 flex flex-col">
{/* Comments List */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{comments.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
<MessageSquare size={20} className="mx-auto mb-1 opacity-50" />
<p className="text-xs">No comments yet</p>
<p className="text-xs">Add a comment to get started</p>
</div>
) : (
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) => (
<Card
key={comment.id}
className="border-border hover:shadow-md transition-shadow cursor-pointer"
onClick={() => {
if (comment.lineNumber && onCommentSelect) {
onCommentSelect(comment.lineNumber, comment.lineRange);
}
}}
>
<CardHeader className="pb-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-foreground">
{comment.author}
</span>
{comment.lineNumber !== null && (
<Badge
variant="secondary"
className="text-xs bg-primary/10 text-primary hover:bg-primary/20 px-1 py-0"
>
{comment.lineRange || `Line ${comment.lineNumber}`}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatTime(comment.timestamp)}
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-foreground whitespace-pre-wrap">
{comment.content}
</p>
</CardContent>
</Card>
))
)}
</div>
{/* Add Comment Form */}
<div className="border-t border-border p-2 bg-muted/20">
<div className="space-y-1">
<Textarea
placeholder={
selectedLine
? `Add a comment on ${selectedLineStart && selectedLineEnd && selectedLineStart !== selectedLineEnd
? `lines ${selectedLineStart}-${selectedLineEnd}`
: `line ${selectedLine}`
}...`
: "Add a general comment..."
}
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[60px] bg-background border-border text-foreground placeholder:text-muted-foreground resize-none text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddComment();
}
// Shift+Enter will naturally add a new line due to default behavior
}}
/>
<div className="text-xs text-muted-foreground text-center">
<kbd className="px-1 py-0 text-xs bg-muted border border-border rounded">
Shift
</kbd>
{' + '}
<kbd className="px-1 py-0 text-xs bg-muted border border-border rounded">
Enter
</kbd>
{' for new line'}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Wifi, WifiOff } from 'lucide-react';
interface ConnectionStatusProps {
isConnected: boolean;
className?: string;
}
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
isConnected,
className = ''
}) => {
return (
<div className={`flex items-center justify-center px-2 py-1 rounded-md text-xs font-medium transition-colors ${
isConnected
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
} ${className}`}>
{isConnected ? (
<Wifi size={14} />
) : (
<WifiOff size={14} />
)}
</div>
);
};

View File

@@ -0,0 +1,420 @@
import React, { useState, useRef, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { MediaModal } from '@/components/MediaModal';
import {
Users,
Circle,
Upload,
File,
ImageIcon,
Video,
Music,
FileText,
Download,
Play,
Pause,
Trash2
} from 'lucide-react';
interface ActiveUser {
id: string;
name: string;
color: string;
lastSeen: Date;
isTyping?: boolean;
currentLine?: number;
}
interface MediaFile {
id: string;
name: string;
type: string;
size: number;
url: string;
uploadedAt: Date;
uploadedBy: string;
}
interface LeftPanelProps {
isVisible: boolean;
isConnected: boolean;
className?: string;
users?: ActiveUser[];
mediaFiles?: MediaFile[];
onFileUpload?: (files: FileList) => void;
onFileDelete?: (fileId: string) => void;
onModalStateChange?: (isOpen: boolean) => void;
}
export const LeftPanel: React.FC<LeftPanelProps> = ({
isVisible,
className = '',
users = [],
mediaFiles = [],
onFileDelete,
onModalStateChange
}) => {
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>(users);
const [localMediaFiles, setLocalMediaFiles] = useState<MediaFile[]>(mediaFiles);
// Update local state when props change
useEffect(() => {
setActiveUsers(users);
}, [users]);
useEffect(() => {
setLocalMediaFiles(mediaFiles);
}, [mediaFiles]);
const [playingAudio, setPlayingAudio] = useState<string | null>(null);
const [modalFile, setModalFile] = useState<MediaFile | null>(null);
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
// Helper function to get the correct file URL using HTTP server
const getFileUrl = (file: MediaFile) => {
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081';
return file.url.startsWith('http') ? file.url : `${httpUrl}${file.url}`;
};
// Helper function to handle modal state changes
const handleModalChange = (file: MediaFile | null) => {
setModalFile(file);
if (onModalStateChange) {
onModalStateChange(file !== null);
}
};
// Users Panel Functions
const getStatusIndicator = (user: ActiveUser) => {
const timeDiff = Date.now() - user.lastSeen.getTime();
if (timeDiff < 60000) { // Less than 1 minute
return { status: 'online', color: 'rgb(184, 187, 38)' }; // success color
} else if (timeDiff < 300000) { // Less than 5 minutes
return { status: 'away', color: 'rgb(250, 189, 47)' }; // warning color
} else {
return { status: 'offline', color: 'rgb(146, 131, 116)' }; // muted color
}
};
const formatLastSeen = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
// Media Panel Functions
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatTimeAgo = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <ImageIcon size={16} className="text-blue-500" />;
if (type.startsWith('video/')) return <Video size={16} className="text-purple-500" />;
if (type.startsWith('audio/')) return <Music size={16} className="text-green-500" />;
if (type.includes('text') || type.includes('json') || type.includes('xml'))
return <FileText size={16} className="text-yellow-500" />;
return <File size={16} className="text-gray-500" />;
};
const getFileTypeColor = (type: string) => {
if (type.startsWith('image/')) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
if (type.startsWith('video/')) return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
if (type.startsWith('audio/')) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
};
const handlePlayAudio = (fileId: string, url: string) => {
// Stop any currently playing audio
if (playingAudio && audioRefs.current[playingAudio]) {
audioRefs.current[playingAudio].pause();
}
if (playingAudio === fileId) {
setPlayingAudio(null);
} else {
if (!audioRefs.current[fileId]) {
audioRefs.current[fileId] = new Audio(url);
audioRefs.current[fileId].addEventListener('ended', () => {
setPlayingAudio(null);
});
}
audioRefs.current[fileId].play();
setPlayingAudio(fileId);
}
};
const handleDeleteFile = (fileId: string) => {
// Call parent delete handler if available
if (onFileDelete) {
onFileDelete(fileId);
}
// Don't update local state here - let the parent's WebSocket update flow through props
// setLocalMediaFiles(prev => prev.filter(file => file.id !== fileId));
// Clean up audio/video refs
if (audioRefs.current[fileId]) {
audioRefs.current[fileId].pause();
delete audioRefs.current[fileId];
}
if (playingAudio === fileId) setPlayingAudio(null);
};
const handleDownload = (file: MediaFile) => {
const link = document.createElement('a');
link.href = getFileUrl(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (!isVisible) {
return null;
}
return (
<div className={`w-64 flex flex-col space-y-4 ${className}`}>
{/* Users Panel */}
<div className="bg-card border border-border rounded-md shadow-lg">
<CardContent className="p-2 space-y-3 max-h-64 overflow-y-auto">
{activeUsers.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
<Users size={20} className="mx-auto mb-2 opacity-50" />
<p className="text-xs">No active users</p>
</div>
) : (
activeUsers.map((user) => {
const { status, color } = getStatusIndicator(user);
return (
<Card key={user.id} className="bg-background border-border">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<div className="relative">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium"
style={{ backgroundColor: user.color }}
>
{user.name.charAt(0).toUpperCase()}
</div>
<Circle
size={8}
className="absolute -bottom-0.5 -right-0.5 border-2 border-background rounded-full"
style={{ color, fill: color }}
/>
</div>
<div>
<p className="text-sm font-medium text-foreground">
{user.name}
</p>
<p className="text-xs text-muted-foreground">
{formatLastSeen(user.lastSeen)}
</p>
</div>
</div>
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: user.color, color: user.color }}
>
{status}
</Badge>
</div>
{user.currentLine && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
Line {user.currentLine}
</span>
{user.isTyping && (
<div className="flex space-x-1">
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{ backgroundColor: user.color }}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.1s'
}}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.2s'
}}
/>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
})
)}
</CardContent>
<div className="px-4 pb-3 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground pt-3">
<span>
{activeUsers.filter(u => getStatusIndicator(u).status === 'online').length} online
</span>
<span>
{activeUsers.filter(u => u.isTyping).length} typing
</span>
</div>
</div>
</div>
{/* Media Panel */}
<div className="bg-card border border-border rounded-md shadow-lg">
<CardContent className="p-2 space-y-3 max-h-64 overflow-y-auto">
{localMediaFiles.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
<Upload size={20} className="mx-auto mb-2 opacity-50" />
<p className="text-xs">No files uploaded</p>
<p className="text-xs">Use upload button in toolbar</p>
</div>
) : (
localMediaFiles.map((file) => (
<Card key={file.id} className="bg-background border-border">
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{getFileIcon(file.type)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} {formatTimeAgo(file.uploadedAt)}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteFile(file.id)}
>
<Trash2 size={12} />
</Button>
</div>
{/* Media preview/player */}
{file.type.startsWith('image/') && (
<div className="mt-2">
<div
className="w-full h-24 bg-cover bg-center rounded border cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundImage: `url(${getFileUrl(file)})` }}
onClick={() => handleModalChange(file)}
title="Click to view full size"
/>
</div>
)}
{file.type.startsWith('video/') && (
<div className="mt-2">
<div
className="w-full h-24 bg-muted rounded border flex items-center justify-center cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => handleModalChange(file)}
title="Click to play video"
>
<Play size={20} className="text-muted-foreground" />
</div>
</div>
)}
{file.type.startsWith('audio/') && (
<div className="mt-2 flex items-center space-x-2">
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePlayAudio(file.id, getFileUrl(file))}
>
{playingAudio === file.id ? (
<Pause size={12} />
) : (
<Play size={12} />
)}
</Button>
<div className="flex-1 h-2 bg-muted rounded-full">
<div className="h-2 bg-primary rounded-full w-0"></div>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-between mt-2">
<Badge
variant="secondary"
className={`text-xs ${getFileTypeColor(file.type)}`}
>
{file.type.split('/')[1] || 'file'}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => handleDownload(file)}
>
<Download size={12} className="mr-1" />
Download
</Button>
</div>
</CardContent>
</Card>
))
)}
</CardContent>
<div className="px-4 pb-3 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground pt-3">
<span>{localMediaFiles.length} files</span>
<span>
{formatFileSize(localMediaFiles.reduce((total, file) => total + file.size, 0))} total
</span>
</div>
</div>
</div>
{/* Media Modal */}
<MediaModal
file={modalFile}
isOpen={modalFile !== null}
onClose={() => handleModalChange(null)}
onDelete={onFileDelete}
getFileUrl={getFileUrl}
/>
</div>
);
};

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Download,
Trash2,
X,
File,
ImageIcon,
Video,
Music,
FileText
} from 'lucide-react';
interface MediaFile {
id: string;
name: string;
type: string;
size: number;
url: string;
uploadedAt: Date;
uploadedBy: string;
}
interface MediaModalProps {
file: MediaFile | null;
isOpen: boolean;
onClose: () => void;
onDelete?: (fileId: string) => void;
getFileUrl: (file: MediaFile) => string;
}
export const MediaModal: React.FC<MediaModalProps> = ({
file,
isOpen,
onClose,
onDelete,
getFileUrl
}) => {
if (!isOpen || !file) return null;
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <ImageIcon size={20} className="text-blue-500" />;
if (type.startsWith('video/')) return <Video size={20} className="text-purple-500" />;
if (type.startsWith('audio/')) return <Music size={20} className="text-green-500" />;
if (type.includes('text') || type.includes('json') || type.includes('xml')) return <FileText size={20} className="text-yellow-500" />;
return <File size={20} className="text-gray-500" />;
};
const getFileTypeColor = (type: string) => {
if (type.startsWith('image/')) return 'text-blue-600 bg-blue-100 dark:bg-blue-900 dark:text-blue-300';
if (type.startsWith('video/')) return 'text-purple-600 bg-purple-100 dark:bg-purple-900 dark:text-purple-300';
if (type.startsWith('audio/')) return 'text-green-600 bg-green-100 dark:bg-green-900 dark:text-green-300';
return 'text-gray-600 bg-gray-100 dark:bg-gray-900 dark:text-gray-300';
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return 'just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
return `${Math.floor(diffInSeconds / 86400)}d ago`;
};
const handleDownload = (file: MediaFile) => {
const link = document.createElement('a');
link.href = getFileUrl(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
return (
<div
className="fixed inset-0 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={onClose}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<Card className="relative max-w-[95vw] max-h-[95vh] w-auto h-auto bg-card border-border shadow-xl flex flex-col">
<div
className="relative flex flex-col h-full"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<CardHeader className="flex flex-row items-center justify-between p-4 border-b border-border">
<div className="flex items-center space-x-2">
{getFileIcon(file.type)}
<div>
<h3 className="text-lg font-semibold text-foreground truncate">
{file.name}
</h3>
<p className="text-sm text-muted-foreground">
{formatFileSize(file.size)} {formatTimeAgo(file.uploadedAt)}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 hover:bg-muted"
onClick={onClose}
>
<X size={16} />
</Button>
</CardHeader>
{/* Media Content */}
<CardContent className="p-0 flex-1 flex items-center justify-center">
<div className="relative w-full h-full flex items-center justify-center min-h-[300px] max-h-[75vh] bg-muted/50">
{file.type.startsWith('image/') && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getFileUrl(file)}
alt={file.name}
className="max-w-full max-h-full object-contain"
style={{
width: 'auto',
height: 'auto',
maxWidth: 'min(90vw, 1200px)',
maxHeight: 'min(75vh, 800px)'
}}
/>
)}
{file.type.startsWith('video/') && (
<video
src={getFileUrl(file)}
controls
autoPlay
className="max-w-full max-h-full object-contain"
style={{
width: 'auto',
height: 'auto',
maxWidth: 'min(90vw, 1200px)',
maxHeight: 'min(75vh, 800px)'
}}
/>
)}
</div>
</CardContent>
{/* Footer */}
<div className="p-4 border-t border-border bg-muted/50 flex-shrink-0">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex items-center space-x-4">
<Badge
variant="secondary"
className={`text-xs ${getFileTypeColor(file.type)}`}
>
{file.type.split('/')[1] || 'file'}
</Badge>
<span className="text-sm text-muted-foreground">
Uploaded by {file.uploadedBy}
</span>
</div>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => handleDownload(file)}
>
<Download size={14} className="mr-2" />
Download
</Button>
{onDelete && (
<Button
size="sm"
variant="outline"
className="hover:bg-destructive hover:text-destructive-foreground"
onClick={() => {
onDelete(file.id);
onClose();
}}
>
<Trash2 size={14} className="mr-2" />
Delete
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
</div>
);
};

View File

@@ -0,0 +1,311 @@
import React, { useState, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Upload,
File,
ImageIcon,
Video,
Music,
FileText,
Download,
Play,
Pause,
Trash2,
X
} from 'lucide-react';
interface MediaFile {
id: string;
name: string;
type: string;
size: number;
url: string;
uploadedAt: Date;
uploadedBy: string;
}
interface MediaPanelProps {
isVisible: boolean;
className?: string;
}
export const MediaPanel: React.FC<MediaPanelProps> = ({
isVisible,
className = ''
}) => {
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([
// Mock data for demonstration
{
id: '1',
name: 'demo-video.mp4',
type: 'video/mp4',
size: 12456789,
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
uploadedAt: new Date(Date.now() - 300000),
uploadedBy: 'Alice'
},
{
id: '2',
name: 'background-music.mp3',
type: 'audio/mpeg',
size: 3456789,
url: 'https://www.soundjay.com/misc/sounds/bell-ringing-05.wav',
uploadedAt: new Date(Date.now() - 600000),
uploadedBy: 'Bob'
},
{
id: '3',
name: 'screenshot.png',
type: 'image/png',
size: 234567,
url: 'https://picsum.photos/800/600',
uploadedAt: new Date(Date.now() - 900000),
uploadedBy: 'You'
}
]);
const [playingAudio, setPlayingAudio] = useState<string | null>(null);
const [playingVideo, setPlayingVideo] = useState<string | null>(null);
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
const videoRefs = useRef<{ [key: string]: HTMLVideoElement }>({});
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatTimeAgo = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return <ImageIcon size={16} className="text-blue-500" />;
if (type.startsWith('video/')) return <Video size={16} className="text-purple-500" />;
if (type.startsWith('audio/')) return <Music size={16} className="text-green-500" />;
if (type.includes('text') || type.includes('json') || type.includes('xml'))
return <FileText size={16} className="text-yellow-500" />;
return <File size={16} className="text-gray-500" />;
};
const getFileTypeColor = (type: string) => {
if (type.startsWith('image/')) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300';
if (type.startsWith('video/')) return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300';
if (type.startsWith('audio/')) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
};
const handlePlayAudio = (fileId: string, url: string) => {
// Stop any currently playing audio
if (playingAudio && audioRefs.current[playingAudio]) {
audioRefs.current[playingAudio].pause();
}
if (playingAudio === fileId) {
setPlayingAudio(null);
} else {
if (!audioRefs.current[fileId]) {
audioRefs.current[fileId] = new Audio(url);
audioRefs.current[fileId].addEventListener('ended', () => {
setPlayingAudio(null);
});
}
audioRefs.current[fileId].play();
setPlayingAudio(fileId);
}
};
const handlePlayVideo = (fileId: string) => {
if (playingVideo === fileId) {
setPlayingVideo(null);
} else {
setPlayingVideo(fileId);
}
};
const handleDeleteFile = (fileId: string) => {
setMediaFiles(prev => prev.filter(file => file.id !== fileId));
// Clean up audio/video refs
if (audioRefs.current[fileId]) {
audioRefs.current[fileId].pause();
delete audioRefs.current[fileId];
}
if (playingAudio === fileId) setPlayingAudio(null);
if (playingVideo === fileId) setPlayingVideo(null);
};
const handleDownload = (file: MediaFile) => {
const link = document.createElement('a');
link.href = file.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (!isVisible) {
return null;
}
return (
<div className={`w-64 bg-card border border-border rounded-md shadow-lg flex flex-col ${className}`}>
{/* Header */}
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Media Files</CardTitle>
</CardHeader>
{/* Files List */}
<CardContent className="p-0 flex-1">
<div className="max-h-80 overflow-y-auto p-4 space-y-3">
{mediaFiles.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Upload size={24} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">No files uploaded</p>
<p className="text-xs">Use upload button in toolbar</p>
</div>
) : (
mediaFiles.map((file) => (
<Card key={file.id} className="bg-background border-border">
<CardContent className="p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{getFileIcon(file.type)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} {formatTimeAgo(file.uploadedAt)}
</p>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteFile(file.id)}
>
<Trash2 size={12} />
</Button>
</div>
<div className="flex items-center justify-between mb-2">
<Badge
variant="secondary"
className={`text-xs ${getFileTypeColor(file.type)}`}
>
{file.type.split('/')[1] || 'file'}
</Badge>
<span className="text-xs text-muted-foreground">
by {file.uploadedBy}
</span>
</div>
{/* Media preview/player */}
{file.type.startsWith('image/') && (
<div className="mt-2">
<div
className="w-full h-24 bg-cover bg-center rounded border"
style={{ backgroundImage: `url(${file.url})` }}
/>
</div>
)}
{file.type.startsWith('video/') && (
<div className="mt-2">
{playingVideo === file.id ? (
<div className="relative">
<video
ref={(el) => {
if (el) videoRefs.current[file.id] = el;
}}
src={file.url}
controls
className="w-full h-24 rounded border"
onEnded={() => setPlayingVideo(null)}
/>
<Button
size="sm"
variant="ghost"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-black/50 hover:bg-black/70"
onClick={() => setPlayingVideo(null)}
>
<X size={12} className="text-white" />
</Button>
</div>
) : (
<div
className="w-full h-24 bg-muted rounded border flex items-center justify-center cursor-pointer hover:bg-muted/80"
onClick={() => handlePlayVideo(file.id)}
>
<Play size={20} className="text-muted-foreground" />
</div>
)}
</div>
)}
{file.type.startsWith('audio/') && (
<div className="mt-2 flex items-center space-x-2">
<Button
size="sm"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePlayAudio(file.id, file.url)}
>
{playingAudio === file.id ? (
<Pause size={12} />
) : (
<Play size={12} />
)}
</Button>
<div className="flex-1 h-2 bg-muted rounded-full">
<div className="h-2 bg-primary rounded-full w-0"></div>
</div>
</div>
)}
{/* Action buttons */}
<div className="flex justify-end mt-2">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={() => handleDownload(file)}
>
<Download size={12} className="mr-1" />
Download
</Button>
</div>
</CardContent>
</Card>
))
)}
</div>
</CardContent>
{/* Footer with file count */}
<div className="p-3 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{mediaFiles.length} files</span>
<span>
{formatFileSize(mediaFiles.reduce((total, file) => total + file.size, 0))} total
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Users, Circle } from 'lucide-react';
interface ActiveUser {
id: string;
name: string;
color: string;
lastSeen: Date;
isTyping?: boolean;
currentLine?: number;
}
interface ActiveUsersPanelProps {
isVisible: boolean;
className?: string;
}
export const ActiveUsersPanel: React.FC<ActiveUsersPanelProps> = ({
isVisible,
className = ''
}) => {
const [activeUsers] = useState<ActiveUser[]>([
// Mock data for demonstration
{
id: '1',
name: 'You',
color: '#b8bb26',
lastSeen: new Date(),
isTyping: false,
currentLine: 15,
},
{
id: '2',
name: 'Alice',
color: '#fb4934',
lastSeen: new Date(Date.now() - 30000), // 30 seconds ago
isTyping: true,
currentLine: 8,
},
{
id: '3',
name: 'Bob',
color: '#83a598',
lastSeen: new Date(Date.now() - 120000), // 2 minutes ago
isTyping: false,
currentLine: 23,
},
]);
const getStatusIndicator = (user: ActiveUser) => {
const timeDiff = Date.now() - user.lastSeen.getTime();
if (timeDiff < 60000) { // Less than 1 minute
return { status: 'online', color: 'rgb(184, 187, 38)' }; // success color
} else if (timeDiff < 300000) { // Less than 5 minutes
return { status: 'away', color: 'rgb(250, 189, 47)' }; // warning color
} else {
return { status: 'offline', color: 'rgb(146, 131, 116)' }; // muted color
}
};
const formatLastSeen = (date: Date) => {
const timeDiff = Date.now() - date.getTime();
if (timeDiff < 60000) {
return 'Just now';
} else if (timeDiff < 3600000) {
const minutes = Math.floor(timeDiff / 60000);
return `${minutes}m ago`;
} else {
const hours = Math.floor(timeDiff / 3600000);
return `${hours}h ago`;
}
};
if (!isVisible) {
return null;
}
return (
<div className={`w-64 bg-card border border-border rounded-md shadow-lg flex flex-col ${className}`}>
{/* Users List */}
<div className="flex-1 overflow-y-auto p-2 space-y-2 max-h-96">
{activeUsers.length === 0 ? (
<div className="text-center text-muted-foreground py-6">
<Users size={20} className="mx-auto mb-1 opacity-50" />
<p className="text-xs">No users</p>
</div>
) : (
activeUsers.map((user) => {
const { status, color } = getStatusIndicator(user);
return (
<Card key={user.id} className="bg-background border-border">
<CardContent className="p-2">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-2">
<div className="relative">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-medium"
style={{ backgroundColor: user.color }}
>
{user.name.charAt(0).toUpperCase()}
</div>
<Circle
size={6}
className="absolute -bottom-0.5 -right-0.5 border border-background rounded-full"
style={{ color, fill: color }}
/>
</div>
<div>
<p className="text-xs font-medium text-foreground">
{user.name}
</p>
<p className="text-xs text-muted-foreground">
{formatLastSeen(user.lastSeen)}
</p>
</div>
</div>
<Badge
variant="outline"
className="text-xs px-1 py-0 h-4"
style={{ borderColor: user.color, color: user.color }}
>
{status}
</Badge>
</div>
{user.currentLine && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
L{user.currentLine}
</span>
{user.isTyping && (
<div className="flex space-x-1">
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{ backgroundColor: user.color }}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.1s'
}}
/>
<div
className="w-1 h-1 rounded-full animate-pulse"
style={{
backgroundColor: user.color,
animationDelay: '0.2s'
}}
/>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
})
)}
</div>
{/* Footer with total count */}
<div className="p-2 border-t border-border bg-muted/50 rounded-b-md">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{activeUsers.filter(u => getStatusIndicator(u).status === 'online').length} online
</span>
<span>
{activeUsers.filter(u => u.isTyping).length} typing
</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,149 @@
"use client";
import React from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
export const BackgroundBeams = React.memo(
({ className }: { className?: string }) => {
const paths = [
"M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875",
"M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867",
"M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859",
"M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851",
"M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843",
"M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835",
"M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827",
"M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819",
"M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811",
"M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803",
"M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795",
"M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787",
"M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779",
"M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771",
"M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763",
"M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755",
"M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747",
"M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739",
"M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731",
"M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723",
"M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715",
"M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707",
"M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699",
"M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691",
"M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683",
"M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675",
"M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667",
"M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659",
"M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651",
"M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643",
"M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635",
"M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627",
"M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619",
"M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611",
"M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603",
"M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595",
"M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587",
"M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579",
"M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571",
"M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563",
"M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555",
"M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547",
"M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539",
"M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531",
"M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523",
"M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515",
"M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507",
"M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499",
"M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491",
"M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483",
];
return (
<div
className={cn(
"absolute h-full w-full inset-0 [mask-size:40px] [mask-repeat:no-repeat] bg-gradient bg-gradient-to-br from-pink-100 via-blue-100 to-purple-100 flex items-center justify-center",
className,
)}
>
<svg
className=" z-0 h-full w-full pointer-events-none absolute "
width="100%"
height="100%"
viewBox="0 0 696 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483M-30 -589C-30 -589 38 -184 502 -57C966 70 1034 475 1034 475M-23 -597C-23 -597 45 -192 509 -65C973 62 1041 467 1041 467M-16 -605C-16 -605 52 -200 516 -73C980 54 1048 459 1048 459M-9 -613C-9 -613 59 -208 523 -81C987 46 1055 451 1055 451M-2 -621C-2 -621 66 -216 530 -89C994 38 1062 443 1062 443M5 -629C5 -629 73 -224 537 -97C1001 30 1069 435 1069 435M12 -637C12 -637 80 -232 544 -105C1008 22 1076 427 1076 427M19 -645C19 -645 87 -240 551 -113C1015 14 1083 419 1083 419"
stroke="url(#paint0_radial_242_278)"
strokeOpacity="0.3"
strokeWidth="0.5"
></path>
{paths.map((path, index) => (
<motion.path
key={`path-` + index}
d={path}
stroke={`url(#linearGradient-${index})`}
strokeOpacity="0.9"
strokeWidth="0.7"
></motion.path>
))}
<defs>
{paths.map((path, index) => (
<motion.linearGradient
id={`linearGradient-${index}`}
key={`gradient-${index}`}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: ["0%", "100%"],
x2: ["0%", "95%"],
y1: ["0%", "100%"],
y2: ["0%", `${93 + Math.random() * 8}%`],
}}
transition={{
duration: Math.random() * 10 + 10,
ease: "easeInOut",
repeat: Infinity,
delay: 0,
}}
>
<stop stopColor="#FFC107" stopOpacity="0"></stop>{" "}
{/* New start color (amber) */}
<stop stopColor="#FF5722"></stop>{" "}
{/* Mid gradient color (deep orange) */}
<stop offset="32.5%" stopColor="#FF9800"></stop>{" "}
{/* Another midpoint color (orange) */}
<stop
offset="100%"
stopColor="#4CAF50"
stopOpacity="0"
></stop>{" "}
{/* End color (green) */}
</motion.linearGradient>
))}
<radialGradient
id="paint0_radial_242_278"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="var(--neutral-300)"></stop>
<stop offset="0.243243" stopColor="var(--neutral-300)"></stop>
<stop offset="0.43594" stopColor="white" stopOpacity="0"></stop>
</radialGradient>
</defs>
</svg>
</div>
);
},
);
BackgroundBeams.displayName = "BackgroundBeams";

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
success:
"border-transparent bg-green-500 text-white shadow hover:bg-green-600",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
" border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,70 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@/lib/utils"
import { MinusIcon } from "@radix-ui/react-icons"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first: first:border-l last:",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<MinusIcon />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,60 @@
// Input component extends from shadcnui - https://ui.shadcn.com/docs/components/input
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMotionTemplate, useMotionValue, motion } from "framer-motion";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
const radius = 100;
const [visible, setVisible] = React.useState(false);
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
function handleMouseMove({ currentTarget, clientX, clientY }: any) {
const { left, top } = currentTarget.getBoundingClientRect();
mouseX.set(clientX - left);
mouseY.set(clientY - top);
}
return (
<motion.div
style={{
background: useMotionTemplate`
radial-gradient(
${visible ? radius + "px" : "0px"} circle at ${mouseX}px ${mouseY}px,
var(--blue-500),
transparent 80%
)
`,
}}
onMouseMove={handleMouseMove}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
className="p-[2px] transition duration-300 group/input"
>
<input
type={type}
className={cn(
`flex h-10 w-full border-none bg-gray-50 dark:bg-zinc-800 text-black dark:text-white shadow-input px-3 py-2 text-sm file:border-0 file:bg-transparent
file:text-sm file:font-medium placeholder:text-neutral-400 dark:placeholder-text-neutral-600
focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-neutral-400 dark:focus-visible:ring-neutral-600
disabled:cursor-not-allowed disabled:opacity-50
dark:shadow-[0px_0px_1px_1px_var(--neutral-700)]
group-hover/input:shadow-none transition duration-400
`,
className,
)}
ref={ref}
{...props}
/>
</motion.div>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

458
client/lib/themes.ts Normal file
View File

@@ -0,0 +1,458 @@
// Top 10 VS Code themes with their color schemes
export interface ThemeConfig {
id: string;
name: string;
type: 'light' | 'dark';
colors: {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover?: string;
popoverForeground?: string;
border: string;
primary: string;
primaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
destructiveForeground: string;
};
}
export const VSCODE_THEMES: ThemeConfig[] = [
{
id: 'one-dark-pro',
name: 'One Dark Pro',
type: 'dark',
colors: {
background: 'hsl(220, 13%, 18%)',
foreground: 'hsl(220, 9%, 55%)',
card: 'hsl(220, 13%, 20%)',
cardForeground: 'hsl(220, 9%, 55%)',
popover: 'hsl(220, 13%, 20%)',
popoverForeground: 'hsl(220, 9%, 55%)',
border: 'hsl(220, 13%, 25%)',
primary: 'hsl(187, 47%, 55%)',
primaryForeground: 'hsl(220, 13%, 18%)',
muted: 'hsl(220, 13%, 22%)',
mutedForeground: 'hsl(220, 9%, 40%)',
accent: 'hsl(220, 13%, 22%)',
accentForeground: 'hsl(220, 9%, 55%)',
destructive: 'hsl(355, 65%, 65%)',
destructiveForeground: 'hsl(220, 13%, 18%)',
}
},
{
id: 'dracula',
name: 'Dracula Official',
type: 'dark',
colors: {
background: 'hsl(231, 15%, 18%)',
foreground: 'hsl(60, 30%, 96%)',
card: 'hsl(232, 14%, 20%)',
cardForeground: 'hsl(60, 30%, 96%)',
border: 'hsl(231, 11%, 27%)',
primary: 'hsl(265, 89%, 78%)',
primaryForeground: 'hsl(231, 15%, 18%)',
muted: 'hsl(232, 14%, 22%)',
mutedForeground: 'hsl(233, 15%, 41%)',
accent: 'hsl(232, 14%, 22%)',
accentForeground: 'hsl(60, 30%, 96%)',
destructive: 'hsl(0, 100%, 67%)',
destructiveForeground: 'hsl(231, 15%, 18%)',
}
},
{
id: 'github-dark',
name: 'GitHub Dark',
type: 'dark',
colors: {
background: 'hsl(220, 13%, 9%)',
foreground: 'hsl(213, 13%, 93%)',
card: 'hsl(215, 28%, 17%)',
cardForeground: 'hsl(213, 13%, 93%)',
border: 'hsl(240, 3%, 25%)',
primary: 'hsl(212, 92%, 45%)',
primaryForeground: 'hsl(0, 0%, 100%)',
muted: 'hsl(215, 28%, 17%)',
mutedForeground: 'hsl(217, 10%, 64%)',
accent: 'hsl(215, 28%, 17%)',
accentForeground: 'hsl(213, 13%, 93%)',
destructive: 'hsl(0, 73%, 57%)',
destructiveForeground: 'hsl(0, 0%, 100%)',
}
},
{
id: 'github-light',
name: 'GitHub Light',
type: 'light',
colors: {
background: 'hsl(0, 0%, 99%)',
foreground: 'hsl(213, 13%, 27%)',
card: 'hsl(210, 29%, 97%)',
cardForeground: 'hsl(213, 13%, 27%)',
border: 'hsl(214, 18%, 86%)',
primary: 'hsl(212, 92%, 45%)',
primaryForeground: 'hsl(0, 0%, 100%)',
muted: 'hsl(214, 32%, 91%)',
mutedForeground: 'hsl(213, 7%, 46%)',
accent: 'hsl(214, 32%, 91%)',
accentForeground: 'hsl(213, 13%, 27%)',
destructive: 'hsl(357, 79%, 65%)',
destructiveForeground: 'hsl(0, 0%, 100%)',
}
},
{
id: 'nord',
name: 'Nord',
type: 'dark',
colors: {
background: 'hsl(220, 16%, 22%)',
foreground: 'hsl(218, 27%, 94%)',
card: 'hsl(220, 17%, 25%)',
cardForeground: 'hsl(218, 27%, 94%)',
border: 'hsl(220, 16%, 36%)',
primary: 'hsl(213, 32%, 52%)',
primaryForeground: 'hsl(220, 16%, 22%)',
muted: 'hsl(220, 17%, 28%)',
mutedForeground: 'hsl(220, 9%, 46%)',
accent: 'hsl(220, 17%, 28%)',
accentForeground: 'hsl(218, 27%, 94%)',
destructive: 'hsl(354, 42%, 56%)',
destructiveForeground: 'hsl(220, 16%, 22%)',
}
},
{
id: 'night-owl',
name: 'Night Owl',
type: 'dark',
colors: {
background: 'hsl(207, 26%, 17%)',
foreground: 'hsl(207, 6%, 76%)',
card: 'hsl(207, 26%, 19%)',
cardForeground: 'hsl(207, 6%, 76%)',
border: 'hsl(207, 26%, 25%)',
primary: 'hsl(194, 100%, 70%)',
primaryForeground: 'hsl(207, 26%, 17%)',
muted: 'hsl(207, 26%, 21%)',
mutedForeground: 'hsl(207, 6%, 56%)',
accent: 'hsl(207, 26%, 21%)',
accentForeground: 'hsl(207, 6%, 76%)',
destructive: 'hsl(5, 74%, 59%)',
destructiveForeground: 'hsl(207, 26%, 17%)',
}
},
{
id: 'monokai-pro',
name: 'Monokai Pro',
type: 'dark',
colors: {
background: 'hsl(60, 3%, 15%)',
foreground: 'hsl(60, 5%, 96%)',
card: 'hsl(60, 3%, 17%)',
cardForeground: 'hsl(60, 5%, 96%)',
border: 'hsl(60, 3%, 22%)',
primary: 'hsl(81, 73%, 55%)',
primaryForeground: 'hsl(60, 3%, 15%)',
muted: 'hsl(60, 3%, 19%)',
mutedForeground: 'hsl(60, 2%, 45%)',
accent: 'hsl(60, 3%, 19%)',
accentForeground: 'hsl(60, 5%, 96%)',
destructive: 'hsl(0, 90%, 67%)',
destructiveForeground: 'hsl(60, 3%, 15%)',
}
},
{
id: 'tokyo-night',
name: 'Tokyo Night',
type: 'dark',
colors: {
background: 'hsl(243, 23%, 12%)',
foreground: 'hsl(225, 27%, 90%)',
card: 'hsl(243, 23%, 15%)',
cardForeground: 'hsl(225, 27%, 90%)',
border: 'hsl(243, 23%, 20%)',
primary: 'hsl(265, 97%, 78%)',
primaryForeground: 'hsl(243, 23%, 12%)',
muted: 'hsl(243, 23%, 17%)',
mutedForeground: 'hsl(225, 14%, 53%)',
accent: 'hsl(243, 23%, 17%)',
accentForeground: 'hsl(225, 27%, 90%)',
destructive: 'hsl(0, 100%, 74%)',
destructiveForeground: 'hsl(243, 23%, 12%)',
}
},
{
id: 'ayu-dark',
name: 'Ayu Dark',
type: 'dark',
colors: {
background: 'hsl(213, 14%, 12%)',
foreground: 'hsl(60, 12%, 79%)',
card: 'hsl(213, 14%, 15%)',
cardForeground: 'hsl(60, 12%, 79%)',
border: 'hsl(213, 14%, 20%)',
primary: 'hsl(39, 100%, 81%)',
primaryForeground: 'hsl(213, 14%, 12%)',
muted: 'hsl(213, 14%, 17%)',
mutedForeground: 'hsl(213, 14%, 40%)',
accent: 'hsl(213, 14%, 17%)',
accentForeground: 'hsl(60, 12%, 79%)',
destructive: 'hsl(3, 100%, 69%)',
destructiveForeground: 'hsl(213, 14%, 12%)',
}
},
{
id: 'synthwave-84',
name: 'SynthWave \'84',
type: 'dark',
colors: {
background: 'hsl(308, 56%, 4%)',
foreground: 'hsl(0, 0%, 88%)',
card: 'hsl(308, 56%, 6%)',
cardForeground: 'hsl(0, 0%, 88%)',
border: 'hsl(308, 56%, 12%)',
primary: 'hsl(290, 100%, 75%)',
primaryForeground: 'hsl(308, 56%, 4%)',
muted: 'hsl(308, 56%, 8%)',
mutedForeground: 'hsl(308, 26%, 35%)',
accent: 'hsl(308, 56%, 8%)',
accentForeground: 'hsl(0, 0%, 88%)',
destructive: 'hsl(330, 100%, 74%)',
destructiveForeground: 'hsl(308, 56%, 4%)',
}
},
{
id: 'light-plus',
name: 'Light+ (default light)',
type: 'light',
colors: {
background: 'hsl(0, 0%, 100%)',
foreground: 'hsl(240, 10%, 4%)',
card: 'hsl(0, 0%, 98%)',
cardForeground: 'hsl(240, 10%, 4%)',
border: 'hsl(214, 32%, 91%)',
primary: 'hsl(210, 98%, 50%)',
primaryForeground: 'hsl(0, 0%, 100%)',
muted: 'hsl(210, 40%, 96%)',
mutedForeground: 'hsl(215, 16%, 47%)',
accent: 'hsl(210, 40%, 96%)',
accentForeground: 'hsl(240, 10%, 4%)',
destructive: 'hsl(0, 84%, 60%)',
destructiveForeground: 'hsl(0, 0%, 100%)',
}
},
{
id: 'monokai',
name: 'Monokai',
type: 'dark',
colors: {
background: 'hsl(70, 8%, 15%)',
foreground: 'hsl(60, 30%, 96%)',
card: 'hsl(70, 8%, 17%)',
cardForeground: 'hsl(60, 30%, 96%)',
border: 'hsl(70, 8%, 22%)',
primary: 'hsl(81, 100%, 74%)',
primaryForeground: 'hsl(70, 8%, 15%)',
muted: 'hsl(70, 8%, 19%)',
mutedForeground: 'hsl(55, 8%, 45%)',
accent: 'hsl(70, 8%, 19%)',
accentForeground: 'hsl(60, 30%, 96%)',
destructive: 'hsl(0, 94%, 74%)',
destructiveForeground: 'hsl(70, 8%, 15%)',
}
},
{
id: 'one-dark-pro',
name: 'One Dark Pro',
type: 'dark',
colors: {
background: 'hsl(220, 13%, 18%)',
foreground: 'hsl(220, 9%, 55%)',
card: 'hsl(220, 13%, 20%)',
cardForeground: 'hsl(220, 9%, 55%)',
border: 'hsl(220, 13%, 25%)',
primary: 'hsl(187, 47%, 55%)',
primaryForeground: 'hsl(220, 13%, 18%)',
muted: 'hsl(220, 13%, 22%)',
mutedForeground: 'hsl(220, 9%, 40%)',
accent: 'hsl(220, 13%, 22%)',
accentForeground: 'hsl(220, 9%, 55%)',
destructive: 'hsl(355, 65%, 65%)',
destructiveForeground: 'hsl(220, 13%, 18%)',
}
},
{
id: 'dracula',
name: 'Dracula',
type: 'dark',
colors: {
background: 'hsl(231, 15%, 18%)',
foreground: 'hsl(60, 30%, 96%)',
card: 'hsl(232, 14%, 20%)',
cardForeground: 'hsl(60, 30%, 96%)',
border: 'hsl(231, 11%, 27%)',
primary: 'hsl(265, 89%, 78%)',
primaryForeground: 'hsl(231, 15%, 18%)',
muted: 'hsl(232, 14%, 22%)',
mutedForeground: 'hsl(233, 15%, 41%)',
accent: 'hsl(232, 14%, 22%)',
accentForeground: 'hsl(60, 30%, 96%)',
destructive: 'hsl(0, 100%, 67%)',
destructiveForeground: 'hsl(231, 15%, 18%)',
}
},
{
id: 'github-light',
name: 'GitHub Light',
type: 'light',
colors: {
background: 'hsl(0, 0%, 99%)',
foreground: 'hsl(213, 13%, 27%)',
card: 'hsl(210, 29%, 97%)',
cardForeground: 'hsl(213, 13%, 27%)',
border: 'hsl(214, 18%, 86%)',
primary: 'hsl(212, 92%, 45%)',
primaryForeground: 'hsl(0, 0%, 100%)',
muted: 'hsl(214, 32%, 91%)',
mutedForeground: 'hsl(213, 7%, 46%)',
accent: 'hsl(214, 32%, 91%)',
accentForeground: 'hsl(213, 13%, 27%)',
destructive: 'hsl(357, 79%, 65%)',
destructiveForeground: 'hsl(0, 0%, 100%)',
}
},
{
id: 'material-theme',
name: 'Material Theme',
type: 'dark',
colors: {
background: 'hsl(219, 28%, 12%)',
foreground: 'hsl(218, 17%, 35%)',
card: 'hsl(219, 28%, 14%)',
cardForeground: 'hsl(218, 17%, 35%)',
border: 'hsl(219, 28%, 18%)',
primary: 'hsl(199, 98%, 48%)',
primaryForeground: 'hsl(219, 28%, 12%)',
muted: 'hsl(219, 28%, 16%)',
mutedForeground: 'hsl(218, 11%, 25%)',
accent: 'hsl(219, 28%, 16%)',
accentForeground: 'hsl(218, 17%, 35%)',
destructive: 'hsl(0, 74%, 67%)',
destructiveForeground: 'hsl(219, 28%, 12%)',
}
},
{
id: 'solarized-dark',
name: 'Solarized Dark',
type: 'dark',
colors: {
background: 'hsl(192, 100%, 11%)',
foreground: 'hsl(44, 87%, 94%)',
card: 'hsl(192, 100%, 13%)',
cardForeground: 'hsl(44, 87%, 94%)',
border: 'hsl(192, 81%, 14%)',
primary: 'hsl(205, 69%, 49%)',
primaryForeground: 'hsl(192, 100%, 11%)',
muted: 'hsl(192, 100%, 15%)',
mutedForeground: 'hsl(186, 8%, 55%)',
accent: 'hsl(192, 100%, 15%)',
accentForeground: 'hsl(44, 87%, 94%)',
destructive: 'hsl(1, 79%, 63%)',
destructiveForeground: 'hsl(192, 100%, 11%)',
}
},
{
id: 'nord',
name: 'Nord',
type: 'dark',
colors: {
background: 'hsl(220, 16%, 22%)',
foreground: 'hsl(218, 27%, 94%)',
card: 'hsl(220, 16%, 24%)',
cardForeground: 'hsl(218, 27%, 94%)',
border: 'hsl(220, 16%, 28%)',
primary: 'hsl(213, 32%, 52%)',
primaryForeground: 'hsl(220, 16%, 22%)',
muted: 'hsl(220, 16%, 26%)',
mutedForeground: 'hsl(220, 16%, 36%)',
accent: 'hsl(220, 16%, 26%)',
accentForeground: 'hsl(218, 27%, 94%)',
destructive: 'hsl(354, 42%, 56%)',
destructiveForeground: 'hsl(220, 16%, 22%)',
}
},
{
id: 'palenight',
name: 'Palenight',
type: 'dark',
colors: {
background: 'hsl(229, 20%, 21%)',
foreground: 'hsl(229, 25%, 87%)',
card: 'hsl(229, 20%, 23%)',
cardForeground: 'hsl(229, 25%, 87%)',
border: 'hsl(229, 20%, 27%)',
primary: 'hsl(207, 82%, 66%)',
primaryForeground: 'hsl(229, 20%, 21%)',
muted: 'hsl(229, 20%, 25%)',
mutedForeground: 'hsl(229, 15%, 35%)',
accent: 'hsl(229, 20%, 25%)',
accentForeground: 'hsl(229, 25%, 87%)',
destructive: 'hsl(0, 79%, 63%)',
destructiveForeground: 'hsl(229, 20%, 21%)',
}
}
];
export const getThemeById = (id: string): ThemeConfig | undefined => {
return VSCODE_THEMES.find(theme => theme.id === id);
};
export const getNextTheme = (currentThemeId: string): ThemeConfig => {
const currentIndex = VSCODE_THEMES.findIndex(theme => theme.id === currentThemeId);
const nextIndex = (currentIndex + 1) % VSCODE_THEMES.length;
return VSCODE_THEMES[nextIndex];
};
// Cookie utilities
export const saveThemeToCookie = (themeId: string): void => {
document.cookie = `vscode-theme=${themeId}; path=/; max-age=${60 * 60 * 24 * 365}`; // 1 year
};
export const getThemeFromCookie = (): string | null => {
const match = document.cookie.match(/(?:^|; )vscode-theme=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
};
export const applyTheme = (theme: ThemeConfig): void => {
const root = document.documentElement;
// Apply CSS custom properties
root.style.setProperty('--background', theme.colors.background);
root.style.setProperty('--foreground', theme.colors.foreground);
root.style.setProperty('--card', theme.colors.card);
root.style.setProperty('--card-foreground', theme.colors.cardForeground);
root.style.setProperty('--popover', theme.colors.popover || theme.colors.card);
root.style.setProperty('--popover-foreground', theme.colors.popoverForeground || theme.colors.cardForeground);
root.style.setProperty('--border', theme.colors.border);
root.style.setProperty('--primary', theme.colors.primary);
root.style.setProperty('--primary-foreground', theme.colors.primaryForeground);
root.style.setProperty('--muted', theme.colors.muted);
root.style.setProperty('--muted-foreground', theme.colors.mutedForeground);
root.style.setProperty('--accent', theme.colors.accent);
root.style.setProperty('--accent-foreground', theme.colors.accentForeground);
root.style.setProperty('--destructive', theme.colors.destructive);
root.style.setProperty('--destructive-foreground', theme.colors.destructiveForeground);
// Apply dark/light class
if (theme.type === 'dark') {
root.classList.add('dark');
root.classList.remove('light');
} else {
root.classList.add('light');
root.classList.remove('dark');
}
};

6
client/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

17
client/next.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
os: false,
};
}
return config;
},
};
export default nextConfig;

10034
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

67
client/package.json Normal file
View File

@@ -0,0 +1,67 @@
{
"name": "room",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-primitive": "^2.0.2",
"@radix-ui/react-slot": "^1.1.2",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@shadcn/ui": "^0.0.4",
"@stitches/react": "^1.2.8",
"@tabler/icons-react": "^3.31.0",
"@tsparticles/engine": "^3.8.1",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.8.1",
"aceternity-ui": "^0.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.3",
"dotenv": "^16.4.7",
"framer-motion": "^11.18.2",
"input-otp": "^1.4.2",
"lodash": "^4.17.21",
"lucide-react": "^0.456.0",
"mini-svg-data-uri": "^1.4.4",
"monaco-editor": "^0.54.0",
"next": "^15.2.4",
"next-themes": "^0.4.6",
"qss": "^3.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.8",
"react-icons": "^5.5.0",
"simplex-noise": "^4.0.3",
"tailwind-gradient-mask-image": "^1.2.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.169.0",
"three-globe": "^2.42.3",
"ws": "^8.18.1"
},
"devDependencies": {
"@babel/preset-typescript": "^7.27.0",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.14",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.5",
"@types/three": "^0.169.0",
"@types/ws": "^8.18.0",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.1",
"eslint-config-next": "15.0.2",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1
client/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
client/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
client/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
client/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
client/public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

1
client/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
client/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

108
client/tailwind.config.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "var(--border)",
input: "var(--input)",
ring: "var(--ring)",
background: "var(--background)",
foreground: "var(--foreground)",
primary: {
DEFAULT: "var(--primary)",
foreground: "var(--primary-foreground)",
},
secondary: {
DEFAULT: "var(--secondary)",
foreground: "var(--secondary-foreground)",
},
destructive: {
DEFAULT: "var(--destructive)",
foreground: "var(--destructive-foreground)",
},
muted: {
DEFAULT: "var(--muted)",
foreground: "var(--muted-foreground)",
},
accent: {
DEFAULT: "var(--accent)",
foreground: "var(--accent-foreground)",
},
popover: {
DEFAULT: "var(--popover)",
foreground: "var(--popover-foreground)",
},
card: {
DEFAULT: "var(--card)",
foreground: "var(--card-foreground)",
},
info: {
DEFAULT: "var(--info)",
foreground: "var(--info-foreground)",
},
warning: {
DEFAULT: "var(--warning)",
foreground: "var(--warning-foreground)",
},
success: {
DEFAULT: "var(--success)",
foreground: "var(--success-foreground)",
},
error: {
DEFAULT: "var(--error)",
foreground: "var(--error-foreground)",
},
chart: {
1: "var(--chart-1)",
2: "var(--chart-2)",
3: "var(--chart-3)",
4: "var(--chart-4)",
5: "var(--chart-5)",
},
},
fontFamily: {
"jetbrains-mono": ["JetBrains Mono", "monospace"],
roboto: ["Roboto", "sans-serif"],
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [tailwindcssAnimate],
} satisfies Config;
export default config;

27
client/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

2
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.db
uploads/

30
server/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Stage 1: Build the Go binary
FROM golang:alpine AS builder
# Set up dependencies and install SQLite development files
RUN apk add --no-cache build-base git sqlite-dev
# Set the working directory
WORKDIR /server
# Copy the Go module files and download dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy the source code
COPY . .
# Build the server binary with CGO enabled
RUN CGO_ENABLED=1 go build -o server server.go
# Stage 2: Run the server
FROM alpine:latest
# Copy the server binary from the builder stage
COPY --from=builder /server/server /server
# Expose the port
EXPOSE 8080
# Run the server
CMD ["/server"]

49
server/README.md Normal file
View File

@@ -0,0 +1,49 @@
# room-server
This project implements the backend server for a collaborative text editor using WebSockets, allowing multiple users to edit text in real-time within designated rooms.
## Features
- Real-time text updates among clients.
- Join and leave functionality for collaborative editing sessions.
- Automatic cleanup of inactive rooms.
- Persistent storage of room content.
## Technologies Used
- WebSockets
- SQLite
## Getting Started
### Prerequisites
- Go
- SQLite
### Usage
1. Clone the repository.
2. Deploy using Docker Compose.
### Using the Application
- Connect to the WebSocket endpoint.
- Send a JSON message to join a room.
- Send text updates in JSON format.
### Room Cleanup
- Inactive rooms are automatically deleted after a specified duration.
## Contributing
Contributions are welcome! Please fork the repository and create a pull request.
## License
This project is open-source and available under the [MIT License](LICENSE).
## Acknowledgments
- Thanks to Me!

12
server/compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
server:
build:
context: .
dockerfile: Dockerfile
ports:
- "8083:8080"
restart: unless-stopped
volumes:
- ./uploads:/app/uploads
environment:
- FILES_DIR=/app/uploads

9
server/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module server
go 1.23.2
require (
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
)

6
server/go.sum Normal file
View File

@@ -0,0 +1,6 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

1133
server/main.go Normal file

File diff suppressed because it is too large Load Diff