init
6
client/.eslintrc.json
Normal 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
@@ -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
@@ -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.
|
||||
38
client/app/api/socket/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
interface ExtendedNextRequest extends NextRequest {
|
||||
socket: {
|
||||
server: {
|
||||
wss?: WebSocketServer;
|
||||
};
|
||||
on: (event: string, listener: (...args: any[]) => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: ExtendedNextRequest) {
|
||||
const { socket } = req;
|
||||
|
||||
if (!socket?.server?.wss) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
socket.server.wss = wss;
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (message) => {
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === client.OPEN) {
|
||||
client.send(message.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("upgrade", (request: any, socket: any, head: any) => {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, request);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("WebSocket setup complete");
|
||||
}
|
||||
BIN
client/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
client/app/fonts/GeistMonoVF.woff
Normal file
BIN
client/app/fonts/GeistVF.woff
Normal file
87
client/app/globals.css
Normal file
@@ -0,0 +1,87 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap");
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: rgb(251, 241, 199);
|
||||
--foreground: rgb(60, 56, 54);
|
||||
--card: rgb(235, 219, 178);
|
||||
--card-foreground: rgb(60, 56, 54);
|
||||
--popover: rgb(235, 219, 178);
|
||||
--popover-foreground: rgb(60, 56, 54);
|
||||
--primary: rgb(211, 134, 155);
|
||||
--primary-foreground: rgb(251, 241, 199);
|
||||
--secondary: rgb(184, 187, 38);
|
||||
--secondary-foreground: rgb(80, 73, 69);
|
||||
--muted: rgb(168, 153, 132);
|
||||
--muted-foreground: rgb(102, 92, 84);
|
||||
--accent: rgb(250, 189, 47);
|
||||
--accent-foreground: rgb(80, 73, 69);
|
||||
--destructive: rgb(204, 36, 29);
|
||||
--destructive-foreground: rgb(251, 241, 199);
|
||||
--border: rgb(213, 196, 161);
|
||||
--input: rgb(213, 196, 161);
|
||||
--ring: rgb(211, 134, 155);
|
||||
--radius: 0.5rem;
|
||||
--chart-1: rgb(250, 189, 47);
|
||||
--chart-2: rgb(131, 165, 152);
|
||||
--chart-3: rgb(69, 133, 136);
|
||||
--chart-4: rgb(254, 128, 25);
|
||||
--chart-5: rgb(251, 73, 52);
|
||||
--info: rgb(131, 165, 152);
|
||||
--info-foreground: rgb(40, 40, 40);
|
||||
--warning: rgb(215, 153, 33);
|
||||
--warning-foreground: rgb(40, 40, 40);
|
||||
--success: rgb(184, 187, 38);
|
||||
--success-foreground: rgb(40, 40, 40);
|
||||
--error: rgb(251, 73, 52);
|
||||
--error-foreground: rgb(40, 40, 40);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: rgb(40, 40, 40);
|
||||
--foreground: rgb(235, 219, 178);
|
||||
--card: rgb(60, 56, 54);
|
||||
--card-foreground: rgb(235, 219, 178);
|
||||
--popover: rgb(60, 56, 54);
|
||||
--popover-foreground: rgb(235, 219, 178);
|
||||
--primary: rgb(211, 134, 155);
|
||||
--primary-foreground: rgb(40, 40, 40);
|
||||
--secondary: rgb(80, 73, 69);
|
||||
--secondary-foreground: rgb(235, 219, 178);
|
||||
--muted: rgb(80, 73, 69);
|
||||
--muted-foreground: rgb(168, 153, 132);
|
||||
--accent: rgb(80, 73, 69);
|
||||
--accent-foreground: rgb(235, 219, 178);
|
||||
--destructive: rgb(204, 36, 29);
|
||||
--destructive-foreground: rgb(235, 219, 178);
|
||||
--border: rgb(102, 92, 84);
|
||||
--input: rgb(102, 92, 84);
|
||||
--ring: rgb(211, 134, 155);
|
||||
--chart-1: rgb(250, 189, 47);
|
||||
--chart-2: rgb(131, 165, 152);
|
||||
--chart-3: rgb(69, 133, 136);
|
||||
--chart-4: rgb(254, 128, 25);
|
||||
--chart-5: rgb(251, 73, 52);
|
||||
--info: rgb(131, 165, 152);
|
||||
--info-foreground: rgb(235, 219, 178);
|
||||
--warning: rgb(215, 153, 33);
|
||||
--warning-foreground: rgb(235, 219, 178);
|
||||
--success: rgb(184, 187, 38);
|
||||
--success-foreground: rgb(235, 219, 178);
|
||||
--error: rgb(251, 73, 52);
|
||||
--error-foreground: rgb(235, 219, 178);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: var(--font-roboto), sans-serif;
|
||||
}
|
||||
textarea {
|
||||
font-family: var(--font-jetbrains-mono), monospace;
|
||||
}
|
||||
}
|
||||
52
client/app/layout.tsx
Normal file
@@ -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
@@ -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
@@ -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
20
client/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
225
client/components/CodeEditor.tsx
Normal 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';
|
||||
179
client/components/CommentsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
client/components/ConnectionStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
420
client/components/LeftPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
205
client/components/MediaModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
311
client/components/MediaPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
177
client/components/UsersPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
client/components/ui/background-beams.tsx
Normal 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";
|
||||
36
client/components/ui/badge.tsx
Normal 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 };
|
||||
57
client/components/ui/button.tsx
Normal 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 };
|
||||
76
client/components/ui/card.tsx
Normal 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 }
|
||||
29
client/components/ui/hover-card.tsx
Normal 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 }
|
||||
70
client/components/ui/input-otp.tsx
Normal 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 }
|
||||
60
client/components/ui/input.tsx
Normal 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 };
|
||||
15
client/components/ui/skeleton.tsx
Normal 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 }
|
||||
23
client/components/ui/textarea.tsx
Normal 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
@@ -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
@@ -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
@@ -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
67
client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
client/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
client/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
client/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
client/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
client/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 524 B |
BIN
client/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
client/public/file.svg
Normal 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
@@ -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
|
After Width: | Height: | Size: 13 KiB |
1
client/public/next.svg
Normal 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
|
After Width: | Height: | Size: 17 KiB |
1
client/public/site.webmanifest
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
}
|
||||