diff --git a/client/src/components/ActionsSidebarWrapper.tsx b/client/src/components/ActionsSidebarWrapper.tsx index a7123bc..b6aefc8 100644 --- a/client/src/components/ActionsSidebarWrapper.tsx +++ b/client/src/components/ActionsSidebarWrapper.tsx @@ -5,22 +5,25 @@ interface ActionsSidebarWrapperProps { children: React.ReactNode; className?: string; show?: boolean; + ref?: React.RefObject; } function ActionsSidebarWrapper({ children, className, + ref, show = true, }: ActionsSidebarWrapperProps) { return ( {show && ( diff --git a/client/src/components/SessionUsersPanel.tsx b/client/src/components/SessionUsersPanel.tsx index 10fa2e4..f931dde 100644 --- a/client/src/components/SessionUsersPanel.tsx +++ b/client/src/components/SessionUsersPanel.tsx @@ -8,6 +8,7 @@ import type { Session } from "../types/Session"; import { getGuestId } from "../lib/guestId"; import { useMe } from "../hooks/useAuth"; +// for test cameras grid const LOCAL_CAMERAS_COUNT = 1; interface SessionUsersPanelProps { @@ -31,6 +32,9 @@ function SessionUsersPanel({ toggleAudio, toggleVideo, updateSpeakingState, + muteParticipant, + disableParticipantVideo, + currentUserId, } = useWebRTC(roomId, autoJoin); const hasLocalStream = localStream !== null; @@ -189,7 +193,14 @@ function SessionUsersPanel({ "z-[100] 2xl:gap-[0.556vw] gap-2", mode === "mini" ? "flex" - : `2xl:p-[2.778vw_5vw_5vw] p-[12px_12px_72px] w-full 2xl:h-dvh grid grid-cols-${gridColumns} relative 2xl:bg-black auto-rows-fr` + : `2xl:p-[2.778vw_5vw_5vw] p-[12px_12px_72px] w-full 2xl:h-dvh min-h-fulla grid relative 2xl:bg-black auto-rows-fr`, + gridColumns === 1 + ? "grid-cols-1" + : gridColumns === 2 + ? "grid-cols-2" + : gridColumns === 3 + ? "grid-cols-3" + : "grid-cols-4" )} > {mode === "full" &&
} @@ -205,10 +216,9 @@ function SessionUsersPanel({ isAdmin={isLocalUserOrganizer} isLocal={true} mediaStream={localStream} - onMute={toggleAudio} - onVideoOff={toggleVideo} - onCanControl={() => console.log("Toggle control")} onSpeakingChange={handleSpeakingChange} + isLocalUserOrganizer={isLocalUserOrganizer} + participantId={currentUserId} className={clsx( mode === "full" && (activeCamerasCount <= 2 ? "m-auto" : "min-w-full min-h-full") @@ -239,11 +249,10 @@ function SessionUsersPanel({ isAdmin={isParticipantOrganizer(participant.id) || undefined} mediaStream={participant.stream} hasLocalMediaPermission={hasLocalStream} - onMute={() => console.log(`Mute user ${participant.id}`)} - onVideoOff={() => console.log(`Video off user ${participant.id}`)} - onCanControl={() => - console.log(`Can control user ${participant.id}`) - } + isLocalUserOrganizer={isLocalUserOrganizer} + participantId={participant.id} + onMuteParticipant={muteParticipant} + onDisableParticipantVideo={disableParticipantVideo} /> ))} diff --git a/client/src/components/popups/ParticipantsPopup.tsx b/client/src/components/popups/ParticipantsPopup.tsx index 4297afe..727cf1e 100644 --- a/client/src/components/popups/ParticipantsPopup.tsx +++ b/client/src/components/popups/ParticipantsPopup.tsx @@ -230,6 +230,7 @@ function ParticipantItem({ {/* Действия только для удаленных участников и только для организатора */} {!isLocal && isOrganizer && ( , diff --git a/client/src/components/popups/QRCodePopup.tsx b/client/src/components/popups/QRCodePopup.tsx index 0f41cbd..1f76b06 100644 --- a/client/src/components/popups/QRCodePopup.tsx +++ b/client/src/components/popups/QRCodePopup.tsx @@ -30,7 +30,7 @@ function QRCodePopup({ link }: QRCodePopupProps) {
{disabled && size === "large" && ( -
+ -
+ )} ); diff --git a/client/src/components/ui/ControlsPopover.tsx b/client/src/components/ui/ControlsPopover.tsx index df68eca..30b5717 100644 --- a/client/src/components/ui/ControlsPopover.tsx +++ b/client/src/components/ui/ControlsPopover.tsx @@ -1,7 +1,7 @@ import FloatingActionButton from "./FloatingActionButton"; import PopoverWrapper from "./PopoverWrapper"; import MoreIcon from "../icons/MoreIcon"; -import { useRef, useState } from "react"; +import { useState } from "react"; import Button from "./Button"; import ChatFilledIcon from "../icons/ChatFilledIcon"; import UsersFilledIcon from "../icons/UsersFilledIcon"; @@ -19,13 +19,12 @@ import type { Session } from "../../types/Session"; interface ControlsPopoverProps { session?: Session; + parentRef: React.RefObject; } -function ControlsPopover({ session }: ControlsPopoverProps) { +function ControlsPopover({ session, parentRef }: ControlsPopoverProps) { const [isOpened, setIsOpened] = useState(false); - const buttonRef = useRef(null); - const ref = useClickAway(() => setIsOpened(false)); const { setPopup } = usePopupStore(); @@ -33,20 +32,21 @@ function ControlsPopover({ session }: ControlsPopoverProps) { function handleClickOpenChatPopup() { setIsOpened(false); - setPopup(); + setPopup(, "chat"); } function handleClickOpenParticipantsPopup() { setIsOpened(false); if (session) { - setPopup(); + setPopup(, "participants"); } } function handleClickOpenSharePopup() { setIsOpened(false); setPopup( - + , + "share" ); } @@ -58,7 +58,6 @@ function ControlsPopover({ session }: ControlsPopoverProps) { return (
setIsOpened(!isOpened)} > @@ -68,7 +67,7 @@ function ControlsPopover({ session }: ControlsPopoverProps) { diff --git a/client/src/components/ui/FloatingActionButton.tsx b/client/src/components/ui/FloatingActionButton.tsx index 3da1897..00d148d 100644 --- a/client/src/components/ui/FloatingActionButton.tsx +++ b/client/src/components/ui/FloatingActionButton.tsx @@ -5,12 +5,14 @@ interface FloatingActionButtonProps extends React.ButtonHTMLAttributes { children: React.ReactNode; variant?: "default" | "critical"; + isActive?: boolean; ref?: React.RefObject; } function FloatingActionButton({ children, variant = "default", + isActive = false, className, onClick, disabled, @@ -25,8 +27,9 @@ function FloatingActionButton({ className={clsx( "2xl:p-[0.833vw] p-3 rounded-full transition-all cursor-pointer disabled:!cursor-default outline-none backdrop-blur-[10px]", variant === "default" && - "2xl:border-[0.069vw] border border-[#FFFFFF]/15 bg-[#000000]/15 hover:bg-[#000000]/25 hover:border-[#FFFFFF]/25 enabled:active:bg-[#7B60F3] enabled:active:border-[#FFFFFF]/25", + "2xl:ring-[0.069vw] ring-1 ring-[#FFFFFF]/15 bg-[#000000]/15 hover:bg-[#000000]/25 hover:ring-[#FFFFFF]/25 enabled:active:bg-[#7B60F3] enabled:active:ring-[#FFFFFF]/25", variant === "critical" && "bg-[#FF4517] hover:bg-[#FF4517]/85", + isActive && variant === "default" && "!bg-[#7B60F3] !ring-[#FFFFFF]/25", className )} {...props} diff --git a/client/src/components/ui/PopoverWrapper.tsx b/client/src/components/ui/PopoverWrapper.tsx index 4e65c86..61b9bca 100644 --- a/client/src/components/ui/PopoverWrapper.tsx +++ b/client/src/components/ui/PopoverWrapper.tsx @@ -4,7 +4,7 @@ import clsx from "clsx"; interface PopoverProps { isOpened: boolean; - parentElRef: React.RefObject; + parentRef: React.RefObject; children: React.ReactNode; className?: string; position?: "side" | "center" | "vertical"; // side - сбоку от элемента, center - по центру экрана, vertical - сверху/снизу @@ -12,28 +12,27 @@ interface PopoverProps { function PopoverWrapper({ isOpened, - parentElRef, + parentRef, children, className, position = "side", }: PopoverProps) { const [openUpwards, setOpenUpwards] = useState(false); const [menuHeight, setMenuHeight] = useState(0); - const [, forceUpdate] = useState(0); useEffect(() => { if ( isOpened && - parentElRef.current && - (position === "side" || position === "vertical") + parentRef.current && + (position === "side" || position === "vertical" || position === "center") ) { - const buttonRect = parentElRef.current.getBoundingClientRect(); - console.log(buttonRect); - const spaceBelow = window.innerHeight - buttonRect.bottom; - const spaceAbove = buttonRect.top; + const parentRect = parentRef.current.getBoundingClientRect(); + console.log(parentRect); + const spaceBelow = window.innerHeight - parentRect.bottom; + const spaceAbove = parentRect.top; setOpenUpwards(spaceBelow < menuHeight && spaceAbove > menuHeight); } - }, [parentElRef, isOpened, menuHeight, position]); + }, [parentRef, isOpened, menuHeight, position]); // Обновляем позицию при изменении положения родительского элемента useEffect(() => { @@ -42,7 +41,6 @@ function PopoverWrapper({ let animationFrameId: number; const updatePosition = () => { - forceUpdate((prev) => prev + 1); animationFrameId = requestAnimationFrame(updatePosition); }; @@ -56,15 +54,17 @@ function PopoverWrapper({ }, [isOpened]); const getPositionStyles = () => { - if (!parentElRef.current) return {}; + if (!parentRef.current) return {}; - const buttonRect = parentElRef.current.getBoundingClientRect(); + const parentRect = parentRef.current.getBoundingClientRect(); if (position === "center") { - // Позиционирование по центру экрана + // Позиционирование по центру относительно parentRef, сверху или снизу return { - left: "50%", - bottom: buttonRect.height + 16, // 16px отступ от кнопки + left: parentRect.left + parentRect.width / 2, + top: openUpwards + ? parentRect.top - menuHeight - 8 + : parentRect.bottom + 8, transform: "translateX(-50%)", }; } @@ -72,19 +72,19 @@ function PopoverWrapper({ if (position === "vertical") { // Позиционирование сверху или снизу от элемента, выравнивание по правому краю return { - left: buttonRect.right, + left: parentRect.right, top: openUpwards - ? buttonRect.top - menuHeight - 4 - : buttonRect.bottom + 4, + ? parentRect.top - menuHeight - 4 + : parentRect.bottom + 4, }; } // Позиционирование сбоку (существующая логика) return { - left: buttonRect.left + buttonRect.width, + left: parentRect.left + parentRect.width, top: openUpwards - ? buttonRect.top - menuHeight - 4 - : buttonRect.bottom + 4, + ? parentRect.top - menuHeight - 4 + : parentRect.bottom + 4, }; }; diff --git a/client/src/components/ui/Tooltip.tsx b/client/src/components/ui/Tooltip.tsx index e24557b..547f056 100644 --- a/client/src/components/ui/Tooltip.tsx +++ b/client/src/components/ui/Tooltip.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { motion } from "motion/react"; import React, { useEffect, useRef, useState } from "react"; @@ -6,6 +7,7 @@ interface TooltipProps { children: React.ReactNode; position?: "top" | "bottom" | "left" | "right" | "cursor"; showDelay?: number; + className?: string; } interface TooltipPosition { @@ -21,6 +23,7 @@ export default function Tooltip({ children, position = "top", showDelay = 500, + className, }: TooltipProps) { const [isVisible, setIsVisible] = useState(false); const [tooltipPosition, setTooltipPosition] = useState({}); @@ -64,7 +67,7 @@ export default function Tooltip({ }, [isVisible, position]); return ( -
+
{children} {isVisible && ( void; - onVideoOff: () => void; - onCanControl: () => void; isAdmin?: boolean; name?: string; mediaStream?: MediaStream | null; @@ -32,15 +31,16 @@ interface UserCameraProps { hasLocalMediaPermission?: boolean; // Есть ли у локального пользователя разрешение на медиа mode: "full" | "mini"; className?: string; + isLocalUserOrganizer?: boolean; // Является ли текущий пользователь организатором сессии + participantId?: string; // ID участника для управления + onMuteParticipant?: (participantId: string) => void; + onDisableParticipantVideo?: (participantId: string) => void; } export default function UserCamera({ isMuted, isVideoOff, hasControl = false, - // onMute, - // onVideoOff, - // onCanControl, isAdmin = false, name = "Гость", mediaStream = null, @@ -50,8 +50,14 @@ export default function UserCamera({ hasLocalMediaPermission = false, mode = "full", className, + isLocalUserOrganizer = false, + participantId, + onMuteParticipant, + onDisableParticipantVideo, }: UserCameraProps) { const ref = useRef(null); + const actionsPopoverParentRef = useRef(null); + // Для удаленных участников: если у локального пользователя есть разрешение на медиа - unmute, иначе mute (для autoplay) const [isAudioMuted, setIsAudioMuted] = useState(!hasLocalMediaPermission); @@ -105,17 +111,6 @@ export default function UserCamera({ // isSpeaking уже учитывает threshold и debounce (1 сек) const ringOpacity = isSpeaking ? 1 : 0; - // Логируем для отладки (отключено для снижения шума) - // useEffect(() => { - // console.log( - // `[${name}${ - // isLocal ? " (local)" : "" - // }] isSpeaking: ${isSpeaking}, ringOpacity: ${ringOpacity.toFixed( - // 2 - // )}, isMuted: ${isMuted}` - // ); - // }, [isSpeaking, ringOpacity, name, isMuted, isLocal]); - useEffect(() => { if (ref.current && mediaStream) { console.log( @@ -295,18 +290,11 @@ export default function UserCamera({ "select-none group relative pointer-events-auto", mode === "mini" ? "aspect-square h-fit flex-shrink-0 2xl:hover:w-[10.833vw] 2xl:w-[6.944vw] sm:w-[15.625vw] w-[27.778vw]" - : "aspect-video max-w-full h-full", + : "aspect-video max-2xl:min-w-full max-2xl:landscape:max-w-full h-full flex flex-col justify-end", isLocal && "order-last", className )} style={{ - // boxShadow: isSpeaking - // ? `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${ - // window.innerWidth >= 1536 ? "0.139vw" : "2px" - // } rgba(34, 197, 94, ${ringOpacity})` - // : `0 4px 40px 0 rgba(15,16,17,0.1), 0 2px 2px 0 rgba(0,0,0,0.06), 0 0 0 ${ - // window.innerWidth >= 1536 ? "0.069vw" : "1px" - // } rgba(255, 255, 255, 0.3)`, transition: mode === "mini" ? "box-shadow 0.1s ease-out, width 0.3s, background-color 0.3s" @@ -317,9 +305,8 @@ export default function UserCamera({