feat: screen share + bottom control bar (Google Meet style)
- Replace VideoConference with custom layout: GridLayout for cameras, FocusLayout when someone shares screen (carousel + focused view) - Bottom control bar: mic, camera, screen share, chat, lobby, moderation, hand raise, leave/end — all as round icon buttons - Top bar reduced to minimal room name + code badge - MediaControls: synced with LiveKit track state via RoomEvents - LeaveButton disconnects and redirects (host → dashboard, guest → home) - Screen share auto-detected: switches to focus layout with carousel
This commit is contained in:
+316
-98
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
LiveKitRoom,
|
||||
VideoConference,
|
||||
GridLayout,
|
||||
RoomAudioRenderer,
|
||||
useTracks,
|
||||
TrackRefContext,
|
||||
ParticipantTile,
|
||||
FocusLayout,
|
||||
FocusLayoutContainer,
|
||||
CarouselLayout,
|
||||
useRoomContext,
|
||||
useLocalParticipant,
|
||||
} from "@livekit/components-react";
|
||||
import { Track, RoomEvent } from "livekit-client";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import ModerationPanel from "@/components/room/ModerationPanel";
|
||||
import ChatPanel from "@/components/room/ChatPanel";
|
||||
@@ -27,6 +37,7 @@ type RoomInfo = {
|
||||
export default function RoomPage() {
|
||||
const params = useParams<{ code: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
@@ -59,9 +70,7 @@ export default function RoomPage() {
|
||||
const roomData = await res.json();
|
||||
setRoom(roomData);
|
||||
|
||||
// If authenticated user is host and no token yet
|
||||
if (!token && userId && roomData.hostId === userId) {
|
||||
// Auto-start room if still WAITING
|
||||
if (roomData.status === "WAITING") {
|
||||
const startRes = await fetch(`/api/rooms/${roomData.id}/start`, {
|
||||
method: "POST",
|
||||
@@ -72,7 +81,6 @@ export default function RoomPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Now fetch token
|
||||
const tokenRes = await fetch("/api/livekit/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -107,7 +115,7 @@ export default function RoomPage() {
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// silent — hand raise also works via LiveKit DataChannels
|
||||
// silent
|
||||
}
|
||||
}, [room, handRaised, userId]);
|
||||
|
||||
@@ -141,102 +149,35 @@ export default function RoomPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-surface-0">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-between px-4 h-14 bg-surface-1 border-b border-border-default shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white font-semibold text-[15px]">{room?.name ?? "Комната"}</h1>
|
||||
{room && (
|
||||
<span className="text-[11px] text-text-muted font-mono bg-surface-2 px-2 py-0.5 rounded-md border border-border-subtle">
|
||||
{room.code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{room?.webinarMode && !isHost && (
|
||||
<button
|
||||
onClick={handleRaiseHand}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
handRaised
|
||||
? "bg-warning/20 text-warning border border-warning/30"
|
||||
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11" />
|
||||
</svg>
|
||||
{handRaised ? "Опустить руку" : "Поднять руку"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{isHost && room?.lobbyEnabled && (
|
||||
<button
|
||||
onClick={() => setShowLobby((v) => !v)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
showLobby
|
||||
? "bg-accent/20 text-accent-hover border border-accent/30"
|
||||
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
Зал ожидания
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowChat((v) => !v)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
showChat
|
||||
? "bg-accent/20 text-accent-hover border border-accent/30"
|
||||
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
Чат
|
||||
</span>
|
||||
</button>
|
||||
{isHost && (
|
||||
<button
|
||||
onClick={() => setShowModeration((v) => !v)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
showModeration
|
||||
? "bg-accent/20 text-accent-hover border border-accent/30"
|
||||
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
Модерация
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<LiveKitRoom
|
||||
serverUrl={LIVEKIT_URL}
|
||||
token={token}
|
||||
connect={true}
|
||||
className="h-screen flex flex-col bg-surface-0"
|
||||
onDisconnected={() => router.push(isHost ? "/dashboard" : "/")}
|
||||
>
|
||||
<RoomAudioRenderer />
|
||||
|
||||
{/* Top bar — minimal, just room info */}
|
||||
<header className="flex items-center px-4 h-12 bg-surface-1/80 backdrop-blur-sm border-b border-border-subtle shrink-0">
|
||||
<h1 className="text-white font-semibold text-sm">{room?.name ?? "Комната"}</h1>
|
||||
{room && (
|
||||
<span className="text-[11px] text-text-muted font-mono bg-surface-2 px-2 py-0.5 rounded-md border border-border-subtle ml-3">
|
||||
{room.code}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<LiveKitRoom
|
||||
serverUrl={LIVEKIT_URL}
|
||||
token={token}
|
||||
connect={true}
|
||||
className="flex flex-1 min-h-0"
|
||||
>
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Video area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<VideoConference />
|
||||
<div className="flex-1 min-w-0 p-2">
|
||||
<VideoArea />
|
||||
</div>
|
||||
|
||||
{/* Sidebars — inside LiveKitRoom for useParticipants() context */}
|
||||
{/* Sidebar */}
|
||||
{sidebarOpen && (
|
||||
<aside className="w-80 shrink-0 border-l border-border-default flex flex-col bg-surface-1 transition-all">
|
||||
<aside className="w-80 shrink-0 border-l border-border-default flex flex-col bg-surface-1">
|
||||
{isHost && showLobby && room && (
|
||||
<div className="border-b border-border-default">
|
||||
<LobbyManager roomId={room.id} />
|
||||
@@ -258,7 +199,284 @@ export default function RoomPage() {
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom control bar — Google Meet style */}
|
||||
<footer className="flex items-center justify-between px-4 h-16 bg-surface-1 border-t border-border-default shrink-0">
|
||||
{/* Left: room time or empty */}
|
||||
<div className="w-48" />
|
||||
|
||||
{/* Center: media controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaControls />
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-border-default mx-1" />
|
||||
|
||||
{/* Hand raise (webinar guests only) */}
|
||||
{room?.webinarMode && !isHost && (
|
||||
<ControlButton
|
||||
active={handRaised}
|
||||
activeColor="warning"
|
||||
onClick={handleRaiseHand}
|
||||
tooltip={handRaised ? "Опустить руку" : "Поднять руку"}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11" />
|
||||
</svg>
|
||||
</ControlButton>
|
||||
)}
|
||||
|
||||
{/* Chat */}
|
||||
<ControlButton
|
||||
active={showChat}
|
||||
onClick={() => setShowChat((v) => !v)}
|
||||
tooltip="Чат"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
</ControlButton>
|
||||
|
||||
{/* Lobby (host only) */}
|
||||
{isHost && room?.lobbyEnabled && (
|
||||
<ControlButton
|
||||
active={showLobby}
|
||||
onClick={() => setShowLobby((v) => !v)}
|
||||
tooltip="Зал ожидания"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
</ControlButton>
|
||||
)}
|
||||
|
||||
{/* Moderation (host only) */}
|
||||
{isHost && (
|
||||
<ControlButton
|
||||
active={showModeration}
|
||||
onClick={() => setShowModeration((v) => !v)}
|
||||
tooltip="Модерация"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
</ControlButton>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-border-default mx-1" />
|
||||
|
||||
{/* Leave / End */}
|
||||
<LeaveButton isHost={isHost} />
|
||||
</div>
|
||||
|
||||
{/* Right: empty spacer */}
|
||||
<div className="w-48" />
|
||||
</footer>
|
||||
</LiveKitRoom>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───── Video area with screen share focus ───── */
|
||||
function VideoArea() {
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: false }
|
||||
);
|
||||
|
||||
const screenShareTracks = tracks.filter(
|
||||
(t) => t.source === Track.Source.ScreenShare
|
||||
);
|
||||
const cameraTracks = tracks.filter(
|
||||
(t) => t.source === Track.Source.Camera
|
||||
);
|
||||
|
||||
// If someone is screen sharing, use focus layout
|
||||
if (screenShareTracks.length > 0) {
|
||||
return (
|
||||
<FocusLayoutContainer className="h-full">
|
||||
<CarouselLayout tracks={cameraTracks} className="h-24">
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
<FocusLayout trackRef={screenShareTracks[0]} className="flex-1" />
|
||||
</FocusLayoutContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GridLayout tracks={cameraTracks} className="h-full">
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───── Media controls: mic, camera, screen share ───── */
|
||||
function MediaControls() {
|
||||
const room = useRoomContext();
|
||||
const { localParticipant } = useLocalParticipant();
|
||||
const [micEnabled, setMicEnabled] = useState(true);
|
||||
const [camEnabled, setCamEnabled] = useState(true);
|
||||
const [screenSharing, setScreenSharing] = useState(false);
|
||||
|
||||
// Sync state with actual track state
|
||||
useEffect(() => {
|
||||
setMicEnabled(localParticipant.isMicrophoneEnabled);
|
||||
setCamEnabled(localParticipant.isCameraEnabled);
|
||||
setScreenSharing(localParticipant.isScreenShareEnabled);
|
||||
}, [
|
||||
localParticipant.isMicrophoneEnabled,
|
||||
localParticipant.isCameraEnabled,
|
||||
localParticipant.isScreenShareEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTrackMuted = () => {
|
||||
setMicEnabled(localParticipant.isMicrophoneEnabled);
|
||||
setCamEnabled(localParticipant.isCameraEnabled);
|
||||
};
|
||||
room.on(RoomEvent.TrackMuted, handleTrackMuted);
|
||||
room.on(RoomEvent.TrackUnmuted, handleTrackMuted);
|
||||
room.on(RoomEvent.LocalTrackPublished, handleTrackMuted);
|
||||
room.on(RoomEvent.LocalTrackUnpublished, handleTrackMuted);
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackMuted, handleTrackMuted);
|
||||
room.off(RoomEvent.TrackUnmuted, handleTrackMuted);
|
||||
room.off(RoomEvent.LocalTrackPublished, handleTrackMuted);
|
||||
room.off(RoomEvent.LocalTrackUnpublished, handleTrackMuted);
|
||||
};
|
||||
}, [room, localParticipant]);
|
||||
|
||||
const toggleMic = async () => {
|
||||
await localParticipant.setMicrophoneEnabled(!micEnabled);
|
||||
setMicEnabled(!micEnabled);
|
||||
};
|
||||
|
||||
const toggleCam = async () => {
|
||||
await localParticipant.setCameraEnabled(!camEnabled);
|
||||
setCamEnabled(!camEnabled);
|
||||
};
|
||||
|
||||
const toggleScreenShare = async () => {
|
||||
await localParticipant.setScreenShareEnabled(!screenSharing);
|
||||
setScreenSharing(!screenSharing);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Microphone */}
|
||||
<button
|
||||
onClick={toggleMic}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
|
||||
micEnabled
|
||||
? "bg-surface-2 hover:bg-surface-3 text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
title={micEnabled ? "Выключить микрофон" : "Включить микрофон"}
|
||||
>
|
||||
{micEnabled ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Camera */}
|
||||
<button
|
||||
onClick={toggleCam}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
|
||||
camEnabled
|
||||
? "bg-surface-2 hover:bg-surface-3 text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
title={camEnabled ? "Выключить камеру" : "Включить камеру"}
|
||||
>
|
||||
{camEnabled ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M12 18.75H4.5a2.25 2.25 0 01-2.25-2.25v-9a2.25 2.25 0 012.25-2.25h9" />
|
||||
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Screen Share */}
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
|
||||
screenSharing
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface-2 hover:bg-surface-3 text-white"
|
||||
}`}
|
||||
title={screenSharing ? "Остановить демонстрацию" : "Демонстрация экрана"}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───── Reusable control button ───── */
|
||||
function ControlButton({
|
||||
active,
|
||||
activeColor = "accent",
|
||||
onClick,
|
||||
tooltip,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
activeColor?: "accent" | "warning";
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeClasses =
|
||||
activeColor === "warning"
|
||||
? "bg-warning/20 text-warning"
|
||||
: "bg-accent/20 text-accent-hover";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-all ${
|
||||
active
|
||||
? activeClasses
|
||||
: "bg-surface-2 hover:bg-surface-3 text-text-secondary hover:text-white"
|
||||
}`}
|
||||
title={tooltip}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───── Leave / End room button ───── */
|
||||
function LeaveButton({ isHost }: { isHost: boolean }) {
|
||||
const room = useRoomContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => room.disconnect()}
|
||||
className="h-11 px-5 rounded-full bg-danger hover:bg-red-600 text-white text-sm font-medium transition-colors flex items-center gap-2"
|
||||
title={isHost ? "Завершить" : "Выйти"}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
{isHost ? "Завершить" : "Выйти"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user