mirror of
https://github.com/arkorty/Osborne.git
synced 2026-03-18 00:57:14 +00:00
style
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&family=Bitcount_Grid_Single&display=swap");
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
@@ -85,3 +85,127 @@
|
|||||||
font-family: var(--font-jetbrains-mono), monospace;
|
font-family: var(--font-jetbrains-mono), monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive panel behavior */
|
||||||
|
@layer utilities {
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.panel-responsive-hide {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
.panel-responsive-hide.right-panel {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.panel-responsive-show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbars but keep functionality */
|
||||||
|
@layer utilities {
|
||||||
|
.hide-scrollbar {
|
||||||
|
/* Firefox */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Internet Explorer and Edge */
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
/* WebKit browsers (Chrome, Safari, Edge) */
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll shadows for indicating scrollable content */
|
||||||
|
.scroll-shadow {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-shadow::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(to bottom,
|
||||||
|
hsl(var(--foreground) / 0.08),
|
||||||
|
hsl(var(--foreground) / 0.04),
|
||||||
|
transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-shadow::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(to top,
|
||||||
|
hsl(var(--foreground) / 0.08),
|
||||||
|
hsl(var(--foreground) / 0.04),
|
||||||
|
transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-shadow.scroll-top::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-shadow.scroll-bottom::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extremely small button styling */
|
||||||
|
@layer utilities {
|
||||||
|
.btn-micro {
|
||||||
|
min-height: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-font {
|
||||||
|
font-family: var(--font-bitcount-grid, "Bitcount Grid Single", monospace);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-input {
|
||||||
|
background-color: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
border-color: var(--border);
|
||||||
|
font-family: var(--font-bitcount-grid, "Bitcount Grid Single", monospace);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import { Bitcount_Grid_Single } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
const bitcountGridSingle = Bitcount_Grid_Single({
|
||||||
|
weight: "400",
|
||||||
|
variable: "--font-bitcount-grid",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
src: "./fonts/GeistVF.woff",
|
src: "./fonts/GeistVF.woff",
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -42,7 +49,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${bitcountGridSingle.variable} antialiased`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -9,13 +9,81 @@ import {
|
|||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
} from "@/components/ui/input-otp";
|
} from "@/components/ui/input-otp";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import Image from "next/image";
|
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import {
|
||||||
|
VSCODE_THEMES,
|
||||||
|
getThemeById,
|
||||||
|
applyTheme,
|
||||||
|
saveThemeToCookie,
|
||||||
|
getThemeFromCookie,
|
||||||
|
} from "@/lib/themes";
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [newRoomCode, setNewRoomCode] = useState("");
|
const [newRoomCode, setNewRoomCode] = useState("");
|
||||||
|
const [currentThemeIndex, setCurrentThemeIndex] = useState(0);
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
|
const nextTheme = useCallback(() => {
|
||||||
|
setCurrentThemeIndex((prevIndex) => {
|
||||||
|
const newIndex = (prevIndex + 1) % VSCODE_THEMES.length;
|
||||||
|
const theme = VSCODE_THEMES[newIndex];
|
||||||
|
applyTheme(theme);
|
||||||
|
saveThemeToCookie(theme.id);
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prevTheme = useCallback(() => {
|
||||||
|
setCurrentThemeIndex((prevIndex) => {
|
||||||
|
const newIndex = prevIndex === 0
|
||||||
|
? VSCODE_THEMES.length - 1
|
||||||
|
: prevIndex - 1;
|
||||||
|
const theme = VSCODE_THEMES[newIndex];
|
||||||
|
applyTheme(theme);
|
||||||
|
saveThemeToCookie(theme.id);
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
|
||||||
|
// Initialize theme from cookie
|
||||||
|
const savedThemeId = getThemeFromCookie();
|
||||||
|
if (savedThemeId) {
|
||||||
|
const themeIndex = VSCODE_THEMES.findIndex(
|
||||||
|
(theme) => theme.id === savedThemeId
|
||||||
|
);
|
||||||
|
if (themeIndex !== -1) {
|
||||||
|
setCurrentThemeIndex(themeIndex);
|
||||||
|
const theme = getThemeById(savedThemeId);
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Apply default theme (first in array)
|
||||||
|
const defaultTheme = VSCODE_THEMES[0];
|
||||||
|
applyTheme(defaultTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple keyboard navigation
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
prevTheme();
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
nextTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyPress);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyPress);
|
||||||
|
};
|
||||||
|
}, [nextTheme, prevTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const joinRoom = () => {
|
const joinRoom = () => {
|
||||||
@@ -38,21 +106,51 @@ const Home = () => {
|
|||||||
router.push(`/room?code=${code}`);
|
router.push(`/room?code=${code}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isClient) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTheme = VSCODE_THEMES[currentThemeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen flex items-center justify-center bg-background dark:bg-background">
|
<div className="relative min-h-screen flex items-center justify-center bg-background dark:bg-background ui-font">
|
||||||
<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">
|
<Card className="relative z-10 max-w-md min-w-96 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">
|
{/* Theme Slider - Simple Version */}
|
||||||
<div className=" m-8 bg-black">
|
<div className="w-full mb-6">
|
||||||
<Image
|
<div className="flex items-center justify-between bg-card rounded-lg p-3 border">
|
||||||
src="/logo.png"
|
<button
|
||||||
alt="Room Logo"
|
onClick={prevTheme}
|
||||||
width={128}
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
height={128}
|
aria-label="Previous theme"
|
||||||
className=""
|
>
|
||||||
/>
|
←
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center flex-1 mx-4">
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{currentTheme.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{currentThemeIndex + 1} of {VSCODE_THEMES.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextTheme}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
aria-label="Next theme"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="flex flex-col items-center space-y-4 font-jetbrains-mono">
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="text-6xl font-bold text-foreground mb-4">
|
||||||
|
Osborne
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<CardContent className="flex flex-col items-center space-y-4 ui-font">
|
||||||
<InputOTP
|
<InputOTP
|
||||||
value={newRoomCode}
|
value={newRoomCode}
|
||||||
onChange={(value) => setNewRoomCode(value.toUpperCase())}
|
onChange={(value) => setNewRoomCode(value.toUpperCase())}
|
||||||
@@ -62,21 +160,17 @@ const Home = () => {
|
|||||||
>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
{[...Array(6)].map((_, index) => (
|
{[...Array(6)].map((_, index) => (
|
||||||
<InputOTPSlot
|
<InputOTPSlot key={index} index={index} className="otp-input" />
|
||||||
key={index}
|
|
||||||
index={index}
|
|
||||||
className="text-foreground bg-background dark:text-foreground dark:bg-background dark:caret-foreground"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</InputOTPGroup>
|
</InputOTPGroup>
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
<span className="text-lg text-foreground/70 dark:text-foreground/70">
|
<span className="text-xl text-foreground/70 ui-font font-medium">
|
||||||
or
|
or
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={createNewRoom}
|
onClick={createNewRoom}
|
||||||
variant="default"
|
variant="default"
|
||||||
className="w-min bg-primary text-primary-foreground text-lg font-semibold hover:bg-primary/80"
|
className="w-min bg-primary text-primary-foreground text-xl font-semibold hover:bg-primary/80 ui-font px-6 py-3"
|
||||||
>
|
>
|
||||||
Create Room
|
Create Room
|
||||||
</Button>
|
</Button>
|
||||||
@@ -86,30 +180,9 @@ const Home = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = () => (
|
const HomeWrapper = () => (
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||||
<Suspense fallback={<SkeletonHome />}>
|
<Home />
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, Suspense, useMemo } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
Suspense,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -8,9 +15,12 @@ import {
|
|||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
HoverCardTrigger,
|
HoverCardTrigger,
|
||||||
} from "@/components/ui/hover-card";
|
} from "@/components/ui/hover-card";
|
||||||
import { Link2, LogOut, Sun, Moon, Upload, WifiOff, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
WifiOff,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { CommentsPanel } from "@/components/CommentsPanel";
|
import { CommentsPanel } from "@/components/RightPanel";
|
||||||
import { CodeEditor, CodeEditorRef } from "@/components/CodeEditor";
|
import { CodeEditor, CodeEditorRef } from "@/components/CodeEditor";
|
||||||
import { LeftPanel } from "@/components/LeftPanel";
|
import { LeftPanel } from "@/components/LeftPanel";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +28,7 @@ import {
|
|||||||
getNextTheme,
|
getNextTheme,
|
||||||
saveThemeToCookie,
|
saveThemeToCookie,
|
||||||
getThemeFromCookie,
|
getThemeFromCookie,
|
||||||
applyTheme
|
applyTheme,
|
||||||
} from "@/lib/themes";
|
} from "@/lib/themes";
|
||||||
import debounce from "lodash/debounce";
|
import debounce from "lodash/debounce";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
@@ -151,16 +161,37 @@ type Message =
|
|||||||
const WS_URL = `${process.env.NEXT_PUBLIC_WS_URL}`;
|
const WS_URL = `${process.env.NEXT_PUBLIC_WS_URL}`;
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
const generateUserId = () => `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const generateUserId = () =>
|
||||||
|
`user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
const generateUserName = () => {
|
const generateUserName = () => {
|
||||||
const adjectives = ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Brown"];
|
const adjectives = [
|
||||||
|
"Red",
|
||||||
|
"Blue",
|
||||||
|
"Green",
|
||||||
|
"Yellow",
|
||||||
|
"Purple",
|
||||||
|
"Orange",
|
||||||
|
"Pink",
|
||||||
|
"Brown",
|
||||||
|
];
|
||||||
const nouns = ["Cat", "Dog", "Bird", "Fish", "Bear", "Lion", "Tiger", "Wolf"];
|
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)]}`;
|
return `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${
|
||||||
|
nouns[Math.floor(Math.random() * nouns.length)]
|
||||||
|
}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateUserColor = () => {
|
const generateUserColor = () => {
|
||||||
const colors = ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c", "#e67e22", "#34495e"];
|
const colors = [
|
||||||
|
"#e74c3c",
|
||||||
|
"#3498db",
|
||||||
|
"#2ecc71",
|
||||||
|
"#f39c12",
|
||||||
|
"#9b59b6",
|
||||||
|
"#1abc9c",
|
||||||
|
"#e67e22",
|
||||||
|
"#34495e",
|
||||||
|
];
|
||||||
return colors[Math.floor(Math.random() * colors.length)];
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,17 +207,19 @@ const Room = () => {
|
|||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [status, setStatus] = useState("Disconnected");
|
const [status, setStatus] = useState("Disconnected");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [commentsVisible, setCommentsVisible] = useState(true);
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [windowWidth, setWindowWidth] = useState(0);
|
|
||||||
const [showDisconnectToast, setShowDisconnectToast] = useState(false);
|
const [showDisconnectToast, setShowDisconnectToast] = useState(false);
|
||||||
const [currentThemeId, setCurrentThemeId] = useState('one-dark-pro');
|
const [currentThemeId, setCurrentThemeId] = useState("one-dark-pro");
|
||||||
const [selectedLineStart, setSelectedLineStart] = useState<number>();
|
const [selectedLineStart, setSelectedLineStart] = useState<number>();
|
||||||
const [selectedLineEnd, setSelectedLineEnd] = useState<number>();
|
const [selectedLineEnd, setSelectedLineEnd] = useState<number>();
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
const [windowWidth, setWindowWidth] = useState(0);
|
||||||
|
const [leftPanelForced, setLeftPanelForced] = useState(false);
|
||||||
|
const [rightPanelForced, setRightPanelForced] = useState(false);
|
||||||
|
const [popupMessage, setPopupMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const contentRef = useRef(content);
|
const contentRef = useRef(content);
|
||||||
|
|
||||||
@@ -196,8 +229,47 @@ const Room = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
|
|
||||||
|
// Set initial window width
|
||||||
|
const handleResize = () => {
|
||||||
|
const newWidth = window.innerWidth;
|
||||||
|
setWindowWidth(newWidth);
|
||||||
|
|
||||||
|
// Force immediate panel state reset when crossing the breakpoint to larger
|
||||||
|
if (newWidth >= 1280) {
|
||||||
|
setLeftPanelForced(false);
|
||||||
|
setRightPanelForced(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
// Add event listener
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Calculate panel visibility based on window width
|
||||||
|
// Minimum width needed: 320px (left) + 640px (main content) + 320px (right) = 1280px
|
||||||
|
const shouldShowPanels = windowWidth >= 1280;
|
||||||
|
|
||||||
|
// Auto-hide forced panels when screen size increases (do this before calculating visibility)
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldShowPanels) {
|
||||||
|
setLeftPanelForced(false);
|
||||||
|
setRightPanelForced(false);
|
||||||
|
}
|
||||||
|
}, [shouldShowPanels]);
|
||||||
|
|
||||||
|
// Calculate final panel visibility - when shouldShowPanels is true, always show panels regardless of forced state
|
||||||
|
const showLeftPanel = shouldShowPanels || (!shouldShowPanels && leftPanelForced);
|
||||||
|
const showRightPanel = shouldShowPanels || (!shouldShowPanels && rightPanelForced);
|
||||||
|
|
||||||
// Initialize theme from cookie
|
// Initialize theme from cookie
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClient) {
|
if (isClient) {
|
||||||
@@ -210,7 +282,7 @@ const Room = () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Apply default theme
|
// Apply default theme
|
||||||
const defaultTheme = getThemeById('one-dark-pro');
|
const defaultTheme = getThemeById("one-dark-pro");
|
||||||
if (defaultTheme) {
|
if (defaultTheme) {
|
||||||
applyTheme(defaultTheme);
|
applyTheme(defaultTheme);
|
||||||
}
|
}
|
||||||
@@ -226,18 +298,6 @@ const Room = () => {
|
|||||||
}
|
}
|
||||||
}, [currentThemeId]);
|
}, [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
|
// Show disconnect toast only if still disconnected after a delay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let showTimer: NodeJS.Timeout | null = null;
|
let showTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -262,21 +322,19 @@ const Room = () => {
|
|||||||
|
|
||||||
// Calculate panel visibility based on viewport width
|
// Calculate panel visibility based on viewport width
|
||||||
// Left panel (256px) + Right panel (320px) + Main content (1280px) + padding (~100px) = ~1956px
|
// 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(
|
const debouncedSend = useMemo(
|
||||||
() => debounce((ws: WebSocket, content: string, code: string) => {
|
() =>
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
debounce((ws: WebSocket, content: string, code: string) => {
|
||||||
const message: TextUpdate = {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
type: "text-update",
|
const message: TextUpdate = {
|
||||||
content,
|
type: "text-update",
|
||||||
code,
|
content,
|
||||||
};
|
code,
|
||||||
ws.send(JSON.stringify(message));
|
};
|
||||||
}
|
ws.send(JSON.stringify(message));
|
||||||
}, 100),
|
}
|
||||||
|
}, 100),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -300,14 +358,14 @@ const Room = () => {
|
|||||||
name: generateUserName(),
|
name: generateUserName(),
|
||||||
color: generateUserColor(),
|
color: generateUserColor(),
|
||||||
lastSeen: new Date(),
|
lastSeen: new Date(),
|
||||||
isTyping: false
|
isTyping: false,
|
||||||
};
|
};
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
|
|
||||||
const message: JoinRoom = {
|
const message: JoinRoom = {
|
||||||
type: "join-room",
|
type: "join-room",
|
||||||
code: roomCode,
|
code: roomCode,
|
||||||
user: user
|
user: user,
|
||||||
};
|
};
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
};
|
};
|
||||||
@@ -328,78 +386,110 @@ const Room = () => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "comments-sync":
|
case "comments-sync":
|
||||||
setComments(message.comments ? message.comments.map(c => ({
|
setComments(
|
||||||
...c,
|
message.comments
|
||||||
timestamp: new Date(c.timestamp)
|
? message.comments.map((c) => ({
|
||||||
})) : []);
|
...c,
|
||||||
|
timestamp: new Date(c.timestamp),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "comment-add":
|
case "comment-add":
|
||||||
setComments(prev => [...prev, {
|
setComments((prev) => [
|
||||||
...message.comment,
|
...prev,
|
||||||
timestamp: new Date(message.comment.timestamp)
|
{
|
||||||
}]);
|
...message.comment,
|
||||||
|
timestamp: new Date(message.comment.timestamp),
|
||||||
|
},
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "comment-update":
|
case "comment-update":
|
||||||
setComments(prev => prev.map(c =>
|
setComments((prev) =>
|
||||||
c.id === message.comment.id
|
prev.map((c) =>
|
||||||
? { ...message.comment, timestamp: new Date(message.comment.timestamp) }
|
c.id === message.comment.id
|
||||||
: c
|
? {
|
||||||
));
|
...message.comment,
|
||||||
|
timestamp: new Date(message.comment.timestamp),
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "comment-delete":
|
case "comment-delete":
|
||||||
setComments(prev => prev.filter(c => c.id !== message.comment.id));
|
setComments((prev) =>
|
||||||
|
prev.filter((c) => c.id !== message.comment.id)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "users-sync":
|
case "users-sync":
|
||||||
setUsers(message.users ? message.users.map(u => ({
|
setUsers(
|
||||||
...u,
|
message.users
|
||||||
lastSeen: new Date(u.lastSeen)
|
? message.users.map((u) => ({
|
||||||
})) : []);
|
...u,
|
||||||
|
lastSeen: new Date(u.lastSeen),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "user-joined":
|
case "user-joined":
|
||||||
setUsers(prev => [...prev, {
|
setUsers((prev) => [
|
||||||
...message.user,
|
...prev,
|
||||||
lastSeen: new Date(message.user.lastSeen)
|
{
|
||||||
}]);
|
...message.user,
|
||||||
|
lastSeen: new Date(message.user.lastSeen),
|
||||||
|
},
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "user-left":
|
case "user-left":
|
||||||
setUsers(prev => prev.filter(u => u.id !== message.user.id));
|
setUsers((prev) => prev.filter((u) => u.id !== message.user.id));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "user-activity":
|
case "user-activity":
|
||||||
setUsers(prev => prev.map(u =>
|
setUsers((prev) =>
|
||||||
u.id === message.userId
|
prev.map((u) =>
|
||||||
? {
|
u.id === message.userId
|
||||||
...u,
|
? {
|
||||||
isTyping: message.isTyping,
|
...u,
|
||||||
currentLine: message.currentLine,
|
isTyping: message.isTyping,
|
||||||
lastSeen: new Date()
|
currentLine: message.currentLine,
|
||||||
}
|
lastSeen: new Date(),
|
||||||
: u
|
}
|
||||||
));
|
: u
|
||||||
|
)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "media-sync":
|
case "media-sync":
|
||||||
setMediaFiles(message.mediaFiles ? message.mediaFiles.map(m => ({
|
setMediaFiles(
|
||||||
...m,
|
message.mediaFiles
|
||||||
uploadedAt: new Date(m.uploadedAt)
|
? message.mediaFiles.map((m) => ({
|
||||||
})) : []);
|
...m,
|
||||||
|
uploadedAt: new Date(m.uploadedAt),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "media-upload":
|
case "media-upload":
|
||||||
setMediaFiles(prev => [...prev, {
|
setMediaFiles((prev) => [
|
||||||
...message.media,
|
...prev,
|
||||||
uploadedAt: new Date(message.media.uploadedAt)
|
{
|
||||||
}]);
|
...message.media,
|
||||||
|
uploadedAt: new Date(message.media.uploadedAt),
|
||||||
|
},
|
||||||
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "media-delete":
|
case "media-delete":
|
||||||
setMediaFiles(prev => prev.filter(m => m.id !== message.media.id));
|
setMediaFiles((prev) =>
|
||||||
|
prev.filter((m) => m.id !== message.media.id)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -464,7 +554,9 @@ const Room = () => {
|
|||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
if (lineRange) {
|
if (lineRange) {
|
||||||
// Parse line range like "5-8"
|
// Parse line range like "5-8"
|
||||||
const [start, end] = lineRange.split('-').map(n => parseInt(n.trim()));
|
const [start, end] = lineRange
|
||||||
|
.split("-")
|
||||||
|
.map((n) => parseInt(n.trim()));
|
||||||
editorRef.current.selectLines(start, end);
|
editorRef.current.selectLines(start, end);
|
||||||
} else {
|
} else {
|
||||||
editorRef.current.selectLines(lineNumber);
|
editorRef.current.selectLines(lineNumber);
|
||||||
@@ -472,23 +564,27 @@ const Room = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddComment = (content: string, lineNumber?: number, lineRange?: string) => {
|
const handleAddComment = (
|
||||||
|
content: string,
|
||||||
|
lineNumber?: number,
|
||||||
|
lineRange?: string
|
||||||
|
) => {
|
||||||
if (!socketRef.current || !currentUser) return;
|
if (!socketRef.current || !currentUser) return;
|
||||||
|
|
||||||
const comment: Comment = {
|
const comment: Comment = {
|
||||||
id: '', // Will be set by server
|
id: "", // Will be set by server
|
||||||
lineNumber: lineNumber || null,
|
lineNumber: lineNumber || null,
|
||||||
lineRange: lineRange,
|
lineRange: lineRange,
|
||||||
author: currentUser.name,
|
author: currentUser.name,
|
||||||
authorId: currentUser.id,
|
authorId: currentUser.id,
|
||||||
content: content,
|
content: content,
|
||||||
timestamp: new Date()
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const message: CommentMessage = {
|
const message: CommentMessage = {
|
||||||
type: "comment-add",
|
type: "comment-add",
|
||||||
code: roomCode!,
|
code: roomCode!,
|
||||||
comment: comment
|
comment: comment,
|
||||||
};
|
};
|
||||||
|
|
||||||
socketRef.current.send(JSON.stringify(message));
|
socketRef.current.send(JSON.stringify(message));
|
||||||
@@ -497,7 +593,7 @@ const Room = () => {
|
|||||||
const handleFileUpload = async (files: FileList) => {
|
const handleFileUpload = async (files: FileList) => {
|
||||||
if (!files || files.length === 0 || !currentUser) return;
|
if (!files || files.length === 0 || !currentUser) return;
|
||||||
|
|
||||||
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081';
|
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8081";
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
@@ -505,13 +601,13 @@ const Room = () => {
|
|||||||
try {
|
try {
|
||||||
// Create form data for file upload
|
// Create form data for file upload
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append("file", file);
|
||||||
formData.append('roomCode', roomCode!);
|
formData.append("roomCode", roomCode!);
|
||||||
formData.append('uploadedBy', currentUser.name);
|
formData.append("uploadedBy", currentUser.name);
|
||||||
|
|
||||||
// Upload file to HTTP server
|
// Upload file to HTTP server
|
||||||
const response = await fetch(`${httpUrl}/upload`, {
|
const response = await fetch(`${httpUrl}/upload`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -524,10 +620,9 @@ const Room = () => {
|
|||||||
// Don't add to local state here - the WebSocket broadcast will handle it
|
// Don't add to local state here - the WebSocket broadcast will handle it
|
||||||
// This prevents duplicate entries when the server broadcasts the upload
|
// This prevents duplicate entries when the server broadcasts the upload
|
||||||
|
|
||||||
console.log('File uploaded successfully:', mediaFile);
|
console.log("File uploaded successfully:", mediaFile);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading file:', error);
|
console.error("Error uploading file:", error);
|
||||||
// You could show a toast notification here
|
// You could show a toast notification here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,11 +631,11 @@ const Room = () => {
|
|||||||
const handleFileDelete = async (fileId: string) => {
|
const handleFileDelete = async (fileId: string) => {
|
||||||
if (!roomCode) return;
|
if (!roomCode) return;
|
||||||
|
|
||||||
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || 'http://localhost:8081';
|
const httpUrl = process.env.NEXT_PUBLIC_HTTP_URL || "http://localhost:8081";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${httpUrl}/delete/${roomCode}/${fileId}`, {
|
const response = await fetch(`${httpUrl}/delete/${roomCode}/${fileId}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -550,13 +645,17 @@ const Room = () => {
|
|||||||
// Don't remove from local state here - the WebSocket broadcast will handle it
|
// Don't remove from local state here - the WebSocket broadcast will handle it
|
||||||
// This prevents issues when the server broadcasts the deletion
|
// This prevents issues when the server broadcasts the deletion
|
||||||
|
|
||||||
console.log('File deleted successfully');
|
console.log("File deleted successfully");
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting file:', error);
|
console.error("Error deleting file:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showPopup = (message: string) => {
|
||||||
|
setPopupMessage(message);
|
||||||
|
setTimeout(() => setPopupMessage(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
if (!isClient) return null;
|
if (!isClient) return null;
|
||||||
|
|
||||||
if (!roomCode) {
|
if (!roomCode) {
|
||||||
@@ -565,41 +664,48 @@ const Room = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen bg-background dark:bg-background">
|
<div className="relative min-h-screen bg-background dark:bg-background ui-font">
|
||||||
<div className="flex justify-center">
|
<div
|
||||||
<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' : ''}`}>
|
className="absolute inset-0 transition-all duration-300"
|
||||||
<div className="flex flex-row items-center justify-between p-2 w-full">
|
style={{
|
||||||
<div className="flex gap-2">
|
left: showLeftPanel ? '320px' : '0px',
|
||||||
|
right: showRightPanel ? '320px' : '0px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center relative z-10 w-full h-full bg-card dark:bg-card shadow-md transition-all duration-300 ${
|
||||||
|
isModalOpen ? "blur-sm" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center justify-between p-1 w-full">
|
||||||
|
<div className="flex gap-1">
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<Button
|
<Button
|
||||||
className="text-foreground bg-secondary"
|
className="text-foreground bg-secondary px-2 py-0 h-5 rounded-sm text-xs btn-micro"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(roomCode);
|
navigator.clipboard.writeText(roomCode);
|
||||||
alert("Room code copied to clipboard!");
|
showPopup("Room code copied to clipboard!");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{roomCode}
|
{roomCode}
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
|
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
|
||||||
copy room code
|
copy room code: {roomCode}
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
className="bg-secondary w-10 hover:bg-secondary/80"
|
className="text-foreground bg-secondary px-2 py-0 h-5 rounded-sm text-xs btn-micro"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(window.location.href);
|
navigator.clipboard.writeText(window.location.href);
|
||||||
alert("Room link copied to clipboard!");
|
showPopup("Room link copied to clipboard!");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link2
|
share
|
||||||
size={16}
|
|
||||||
className="text-foreground"
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
|
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
|
||||||
@@ -609,11 +715,11 @@ const Room = () => {
|
|||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<Button
|
<Button
|
||||||
className="bg-destructive w-10 hover:bg-destructive/80"
|
className="bg-destructive px-2 py-0 h-5 text-xs rounded-sm hover:bg-destructive/80 btn-micro"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
>
|
>
|
||||||
<LogOut size={16} className="text-destructive-foreground" />
|
exit
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
|
<HoverCardContent className="py-1 px-2 w-auto text-popover-foreground bg-popover text-xs border-foreground">
|
||||||
@@ -621,17 +727,17 @@ const Room = () => {
|
|||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1">
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<Button
|
<Button
|
||||||
className="w-10 bg-chart-2 hover:bg-chart-2/80"
|
className="bg-chart-2 px-2 py-0 h-5 text-xs rounded-sm hover:bg-chart-2/80 btn-micro"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('Upload button clicked');
|
console.log("Upload button clicked");
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload size={16} className="text-foreground" />
|
upload
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="py-1 px-2 w-auto text-xs border-foreground">
|
<HoverCardContent className="py-1 px-2 w-auto text-xs border-foreground">
|
||||||
@@ -641,7 +747,7 @@ const Room = () => {
|
|||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<Button
|
<Button
|
||||||
className="w-10 bg-chart-4 hover:bg-chart-4/80"
|
className="bg-chart-4 px-2 py-0 h-5 text-xs rounded-sm hover:bg-chart-4/80 btn-micro"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const nextTheme = getNextTheme(currentThemeId);
|
const nextTheme = getNextTheme(currentThemeId);
|
||||||
setCurrentThemeId(nextTheme.id);
|
setCurrentThemeId(nextTheme.id);
|
||||||
@@ -649,20 +755,16 @@ const Room = () => {
|
|||||||
saveThemeToCookie(nextTheme.id);
|
saveThemeToCookie(nextTheme.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getThemeById(currentThemeId)?.type === "dark" ? (
|
theme
|
||||||
<Sun size={16} className="text-foreground" />
|
|
||||||
) : (
|
|
||||||
<Moon size={16} className="text-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="py-1 px-2 w-auto text-xs border-foreground">
|
<HoverCardContent className="py-1 px-2 w-auto text-xs border-foreground">
|
||||||
{getThemeById(currentThemeId)?.name || 'Switch theme'}
|
{getThemeById(currentThemeId)?.name || "Switch theme"}
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow flex flex-col p-2 w-full">
|
<div className="flex-grow flex flex-col p-1 w-full">
|
||||||
{error && status !== "Connected" && (
|
{error && status !== "Connected" && (
|
||||||
<div className="mb-2 p-2 bg-destructive/10 text-destructive rounded text-sm">
|
<div className="mb-2 p-2 bg-destructive/10 text-destructive rounded text-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -692,15 +794,15 @@ const Room = () => {
|
|||||||
if (e.target.files) {
|
if (e.target.files) {
|
||||||
handleFileUpload(e.target.files);
|
handleFileUpload(e.target.files);
|
||||||
// Reset the input so the same file can be selected again
|
// Reset the input so the same file can be selected again
|
||||||
e.target.value = '';
|
e.target.value = "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Comments Panel */}
|
{/* Comments Panel */}
|
||||||
<CommentsPanel
|
<CommentsPanel
|
||||||
isVisible={commentsVisibleResponsive}
|
isVisible={showRightPanel}
|
||||||
onToggle={() => setCommentsVisible(!commentsVisible)}
|
onToggle={() => setRightPanelForced(!rightPanelForced)}
|
||||||
selectedLineStart={selectedLineStart}
|
selectedLineStart={selectedLineStart}
|
||||||
selectedLineEnd={selectedLineEnd}
|
selectedLineEnd={selectedLineEnd}
|
||||||
onCommentSelect={handleCommentSelect}
|
onCommentSelect={handleCommentSelect}
|
||||||
@@ -709,22 +811,37 @@ const Room = () => {
|
|||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Left Panel (Users, Media & ECG) */}
|
{/* Custom Popup */}
|
||||||
{leftPanelVisible && (
|
{popupMessage && (
|
||||||
<div className="fixed top-4 left-4 z-40">
|
<div className="fixed top-4 right-4 z-50">
|
||||||
<LeftPanel
|
<div className="px-3 py-2 bg-popover text-popover-foreground border border-border rounded-lg shadow-lg animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
isVisible={leftPanelVisible}
|
<span className="text-sm font-medium">{popupMessage}</span>
|
||||||
isConnected={status === "Connected"}
|
|
||||||
users={users}
|
|
||||||
mediaFiles={mediaFiles}
|
|
||||||
onFileUpload={handleFileUpload}
|
|
||||||
onFileDelete={handleFileDelete}
|
|
||||||
onModalStateChange={setIsModalOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Disconnect Toast */}
|
{/* Overlay for mobile when panels are forced open */}
|
||||||
|
{!shouldShowPanels && (leftPanelForced || rightPanelForced) && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/20 z-30"
|
||||||
|
onClick={() => {
|
||||||
|
setLeftPanelForced(false);
|
||||||
|
setRightPanelForced(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left Panel (Users, Media & ECG) */}
|
||||||
|
<LeftPanel
|
||||||
|
isVisible={showLeftPanel}
|
||||||
|
users={users}
|
||||||
|
mediaFiles={mediaFiles}
|
||||||
|
onFileUpload={handleFileUpload}
|
||||||
|
onFileDelete={handleFileDelete}
|
||||||
|
onModalStateChange={setIsModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Comments Panel */}
|
||||||
{showDisconnectToast && (
|
{showDisconnectToast && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
||||||
{/* Blurred overlay */}
|
{/* Blurred overlay */}
|
||||||
@@ -733,13 +850,13 @@ const Room = () => {
|
|||||||
<div
|
<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"
|
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={{
|
style={{
|
||||||
background: 'var(--popover, var(--card, #fff))',
|
background: "var(--popover, var(--card, #fff))",
|
||||||
color: 'var(--popover-foreground, var(--foreground, #222))',
|
color: "var(--popover-foreground, var(--foreground, #222))",
|
||||||
borderColor: 'var(--border, #e5e7eb)',
|
borderColor: "var(--border, #e5e7eb)",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: 'solid',
|
borderStyle: "solid",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
width: 'auto',
|
width: "auto",
|
||||||
minWidth: undefined,
|
minWidth: undefined,
|
||||||
maxWidth: undefined,
|
maxWidth: undefined,
|
||||||
}}
|
}}
|
||||||
@@ -750,7 +867,7 @@ const Room = () => {
|
|||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="ml-2 bg-primary/10 hover:bg-primary/20 text-primary rounded p-1 transition-colors"
|
className="ml-2 bg-primary/10 hover:bg-primary/20 text-primary rounded p-1 transition-colors"
|
||||||
title="Refresh to reconnect"
|
title="Refresh to reconnect"
|
||||||
style={{ display: 'flex', alignItems: 'center' }}
|
style={{ display: "flex", alignItems: "center" }}
|
||||||
>
|
>
|
||||||
<RefreshCw size={15} />
|
<RefreshCw size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -45,6 +45,9 @@ export const CommentsPanel: React.FC<CommentsPageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
const [selectedLine, setSelectedLine] = useState<number | null>(null);
|
const [selectedLine, setSelectedLine] = useState<number | null>(null);
|
||||||
|
const [scrollState, setScrollState] = useState({ top: false, bottom: false });
|
||||||
|
|
||||||
|
const commentsScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Update selected line when editor selection changes
|
// Update selected line when editor selection changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,6 +62,36 @@ export const CommentsPanel: React.FC<CommentsPageProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedLineStart, selectedLineEnd]);
|
}, [selectedLineStart, selectedLineEnd]);
|
||||||
|
|
||||||
|
// Scroll detection function
|
||||||
|
const handleScroll = () => {
|
||||||
|
const element = commentsScrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
|
const isScrolledFromTop = scrollTop > 5;
|
||||||
|
const isScrolledFromBottom = scrollTop < scrollHeight - clientHeight - 5;
|
||||||
|
|
||||||
|
setScrollState({
|
||||||
|
top: isScrolledFromTop,
|
||||||
|
bottom: isScrolledFromBottom && scrollHeight > clientHeight
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add scroll listener
|
||||||
|
useEffect(() => {
|
||||||
|
const element = commentsScrollRef.current;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.addEventListener('scroll', handleScroll);
|
||||||
|
// Initial check
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
const handleAddComment = () => {
|
const handleAddComment = () => {
|
||||||
if (newComment.trim() && onAddComment && currentUser) {
|
if (newComment.trim() && onAddComment && currentUser) {
|
||||||
const lineRange = selectedLineStart && selectedLineEnd && selectedLineStart !== selectedLineEnd
|
const lineRange = selectedLineStart && selectedLineEnd && selectedLineStart !== selectedLineEnd
|
||||||
@@ -74,14 +107,19 @@ export const CommentsPanel: React.FC<CommentsPageProps> = ({
|
|||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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">
|
<div
|
||||||
|
className={`fixed right-0 top-0 h-full w-80 bg-card border-l border-border shadow-lg z-40 flex flex-col transition-transform duration-300 ease-in-out ui-font ${
|
||||||
|
isVisible ? 'transform-none' : 'translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* Comments List */}
|
{/* Comments List */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
<div
|
||||||
|
ref={commentsScrollRef}
|
||||||
|
className={`flex-1 overflow-y-auto hide-scrollbar scroll-shadow p-2 space-y-2 ${
|
||||||
|
scrollState.top ? 'scroll-top' : ''
|
||||||
|
} ${scrollState.bottom ? 'scroll-bottom' : ''}`}
|
||||||
|
>
|
||||||
{comments.length === 0 ? (
|
{comments.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-4">
|
<div className="text-center text-muted-foreground py-4">
|
||||||
<MessageSquare size={20} className="mx-auto mb-1 opacity-50" />
|
<MessageSquare size={20} className="mx-auto mb-1 opacity-50" />
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ interface MediaFile {
|
|||||||
|
|
||||||
interface LeftPanelProps {
|
interface LeftPanelProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isConnected: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
users?: ActiveUser[];
|
users?: ActiveUser[];
|
||||||
mediaFiles?: MediaFile[];
|
mediaFiles?: MediaFile[];
|
||||||
@@ -50,7 +49,6 @@ interface LeftPanelProps {
|
|||||||
|
|
||||||
export const LeftPanel: React.FC<LeftPanelProps> = ({
|
export const LeftPanel: React.FC<LeftPanelProps> = ({
|
||||||
isVisible,
|
isVisible,
|
||||||
className = '',
|
|
||||||
users = [],
|
users = [],
|
||||||
mediaFiles = [],
|
mediaFiles = [],
|
||||||
onFileDelete,
|
onFileDelete,
|
||||||
@@ -58,12 +56,57 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>(users);
|
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>(users);
|
||||||
const [localMediaFiles, setLocalMediaFiles] = useState<MediaFile[]>(mediaFiles);
|
const [localMediaFiles, setLocalMediaFiles] = useState<MediaFile[]>(mediaFiles);
|
||||||
|
const [usersScrollState, setUsersScrollState] = useState({ top: false, bottom: false });
|
||||||
|
const [mediaScrollState, setMediaScrollState] = useState({ top: false, bottom: false });
|
||||||
|
|
||||||
|
const usersScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mediaScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Update local state when props change
|
// Update local state when props change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveUsers(users);
|
setActiveUsers(users);
|
||||||
}, [users]);
|
}, [users]);
|
||||||
|
|
||||||
|
// Scroll detection function
|
||||||
|
const handleScroll = (element: HTMLDivElement | null, setState: (state: { top: boolean; bottom: boolean }) => void) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
|
const isScrolledFromTop = scrollTop > 5;
|
||||||
|
const isScrolledFromBottom = scrollTop < scrollHeight - clientHeight - 5;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
top: isScrolledFromTop,
|
||||||
|
bottom: isScrolledFromBottom && scrollHeight > clientHeight
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add scroll listeners
|
||||||
|
useEffect(() => {
|
||||||
|
const usersElement = usersScrollRef.current;
|
||||||
|
const mediaElement = mediaScrollRef.current;
|
||||||
|
|
||||||
|
const handleUsersScroll = () => handleScroll(usersElement, setUsersScrollState);
|
||||||
|
const handleMediaScroll = () => handleScroll(mediaElement, setMediaScrollState);
|
||||||
|
|
||||||
|
if (usersElement) {
|
||||||
|
usersElement.addEventListener('scroll', handleUsersScroll);
|
||||||
|
// Initial check
|
||||||
|
handleUsersScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaElement) {
|
||||||
|
mediaElement.addEventListener('scroll', handleMediaScroll);
|
||||||
|
// Initial check
|
||||||
|
handleMediaScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (usersElement) usersElement.removeEventListener('scroll', handleUsersScroll);
|
||||||
|
if (mediaElement) mediaElement.removeEventListener('scroll', handleMediaScroll);
|
||||||
|
};
|
||||||
|
}, [activeUsers, localMediaFiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalMediaFiles(mediaFiles);
|
setLocalMediaFiles(mediaFiles);
|
||||||
}, [mediaFiles]);
|
}, [mediaFiles]);
|
||||||
@@ -195,15 +238,27 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-64 flex flex-col space-y-4 ${className}`}>
|
<div
|
||||||
|
className={`fixed left-0 top-0 h-full w-80 bg-card border-r border-border shadow-lg z-40 flex flex-col transition-transform duration-300 ease-in-out ui-font ${
|
||||||
|
isVisible ? 'transform-none' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* Users Panel */}
|
{/* Users Panel */}
|
||||||
<div className="bg-card border border-border rounded-md shadow-lg">
|
<div className="h-1/2 flex flex-col border-b border-border">
|
||||||
<CardContent className="p-2 space-y-3 max-h-64 overflow-y-auto">
|
<div className="flex items-center justify-center py-2 border-b border-border/50 bg-muted/20">
|
||||||
|
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||||
|
<Users size={16} />
|
||||||
|
Users
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={usersScrollRef}
|
||||||
|
className={`flex-1 overflow-y-auto hide-scrollbar scroll-shadow p-2 space-y-2 ${
|
||||||
|
usersScrollState.top ? 'scroll-top' : ''
|
||||||
|
} ${usersScrollState.bottom ? 'scroll-bottom' : ''}`}
|
||||||
|
>
|
||||||
{activeUsers.length === 0 ? (
|
{activeUsers.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-4">
|
<div className="text-center text-muted-foreground py-4">
|
||||||
<Users size={20} className="mx-auto mb-2 opacity-50" />
|
<Users size={20} className="mx-auto mb-2 opacity-50" />
|
||||||
@@ -282,9 +337,10 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<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">
|
<div className="px-4 py-3 border-t border-border bg-muted/20">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{activeUsers.filter(u => getStatusIndicator(u).status === 'online').length} online
|
{activeUsers.filter(u => getStatusIndicator(u).status === 'online').length} online
|
||||||
</span>
|
</span>
|
||||||
@@ -296,8 +352,20 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Media Panel */}
|
{/* Media Panel */}
|
||||||
<div className="bg-card border border-border rounded-md shadow-lg">
|
<div className="h-1/2 flex flex-col">
|
||||||
<CardContent className="p-2 space-y-3 max-h-64 overflow-y-auto">
|
<div className="flex items-center justify-center py-2 border-b border-border/50 bg-muted/20">
|
||||||
|
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||||
|
<Upload size={16} />
|
||||||
|
Media
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={mediaScrollRef}
|
||||||
|
className={`flex-1 overflow-y-auto hide-scrollbar scroll-shadow p-2 space-y-2 ${
|
||||||
|
mediaScrollState.top ? 'scroll-top' : ''
|
||||||
|
} ${mediaScrollState.bottom ? 'scroll-bottom' : ''}`}
|
||||||
|
>
|
||||||
{localMediaFiles.length === 0 ? (
|
{localMediaFiles.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-4">
|
<div className="text-center text-muted-foreground py-4">
|
||||||
<Upload size={20} className="mx-auto mb-2 opacity-50" />
|
<Upload size={20} className="mx-auto mb-2 opacity-50" />
|
||||||
@@ -396,9 +464,10 @@ export const LeftPanel: React.FC<LeftPanelProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
<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">
|
<div className="px-4 py-3 border-t border-border bg-muted/20">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>{localMediaFiles.length} files</span>
|
<span>{localMediaFiles.length} files</span>
|
||||||
<span>
|
<span>
|
||||||
{formatFileSize(localMediaFiles.reduce((total, file) => total + file.size, 0))} total
|
{formatFileSize(localMediaFiles.reduce((total, file) => total + file.size, 0))} total
|
||||||
|
|||||||
217
client/components/RightPanel.tsx
Normal file
217
client/components/RightPanel.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import React, { useState, useEffect, useRef } 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);
|
||||||
|
const [scrollState, setScrollState] = useState({ top: false, bottom: false });
|
||||||
|
|
||||||
|
const commentsScrollRef = useRef<HTMLDivElement>(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]);
|
||||||
|
|
||||||
|
// Scroll detection function
|
||||||
|
const handleScroll = () => {
|
||||||
|
const element = commentsScrollRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
|
const isScrolledFromTop = scrollTop > 5;
|
||||||
|
const isScrolledFromBottom = scrollTop < scrollHeight - clientHeight - 5;
|
||||||
|
|
||||||
|
setScrollState({
|
||||||
|
top: isScrolledFromTop,
|
||||||
|
bottom: isScrolledFromBottom && scrollHeight > clientHeight
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add scroll listener
|
||||||
|
useEffect(() => {
|
||||||
|
const element = commentsScrollRef.current;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.addEventListener('scroll', handleScroll);
|
||||||
|
// Initial check
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
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' });
|
||||||
|
};
|
||||||
|
|
||||||
|
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 transition-transform duration-300 ease-in-out ${
|
||||||
|
isVisible ? 'transform-none' : 'translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Comments List */}
|
||||||
|
<div
|
||||||
|
ref={commentsScrollRef}
|
||||||
|
className={`flex-1 overflow-y-auto hide-scrollbar scroll-shadow p-2 space-y-2 ${
|
||||||
|
scrollState.top ? 'scroll-top' : ''
|
||||||
|
} ${scrollState.bottom ? 'scroll-bottom' : ''}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -419,11 +419,11 @@ export const getNextTheme = (currentThemeId: string): ThemeConfig => {
|
|||||||
|
|
||||||
// Cookie utilities
|
// Cookie utilities
|
||||||
export const saveThemeToCookie = (themeId: string): void => {
|
export const saveThemeToCookie = (themeId: string): void => {
|
||||||
document.cookie = `vscode-theme=${themeId}; path=/; max-age=${60 * 60 * 24 * 365}`; // 1 year
|
document.cookie = `theme=${themeId}; path=/; max-age=${60 * 60 * 24 * 365}`; // 1 year
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getThemeFromCookie = (): string | null => {
|
export const getThemeFromCookie = (): string | null => {
|
||||||
const match = document.cookie.match(/(?:^|; )vscode-theme=([^;]*)/);
|
const match = document.cookie.match(/(?:^|; )theme=([^;]*)/);
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const config = {
|
|||||||
fontFamily: {
|
fontFamily: {
|
||||||
"jetbrains-mono": ["JetBrains Mono", "monospace"],
|
"jetbrains-mono": ["JetBrains Mono", "monospace"],
|
||||||
roboto: ["Roboto", "sans-serif"],
|
roboto: ["Roboto", "sans-serif"],
|
||||||
|
"bitcount-grid": ["var(--font-bitcount-grid)", "monospace"],
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|||||||
Reference in New Issue
Block a user