diff --git a/frontend/src/components/room/HandQueueMenu.tsx b/frontend/src/components/room/HandQueueMenu.tsx new file mode 100644 index 0000000..f0da65d --- /dev/null +++ b/frontend/src/components/room/HandQueueMenu.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from 'react' +import { Hand, ListOrdered } from 'lucide-react' +import { useIsOrganizer } from '@/hooks/useIsOrganizer' +import type { HandQueueEntry } from '@/hooks/useChat' + +interface HandQueueMenuProps { + queue: HandQueueEntry[] + onLower: (identity: string) => void +} + +/** + * Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору + * (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»): + * собственное состояние открытия, закрытие по клику вне/Escape, поповер + * `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат): + * очередь рук — короткий список, а не история переписки, разворачивать её + * во весь экран незачем и на мобильном. + * + * Размер поповера подстраивается под число записей — `.hand-queue-list` + * растёт вместе со списком и не даёт пустого места при 1–2 поднятых руках, + * но не бесконечно: после ~10 строк список упирается в `max-height` и дальше + * скроллится (см. room.css) — иначе организатор на энергичной встрече + * получил бы поповер выше экрана. + */ +export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) { + const isOrganizer = useIsOrganizer() + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + + useEffect(() => { + if (!open) return + + function handlePointerDown(event: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) { + setOpen(false) + } + } + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') setOpen(false) + } + + document.addEventListener('mousedown', handlePointerDown) + document.addEventListener('keydown', handleKeydown) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + document.removeEventListener('keydown', handleKeydown) + } + }, [open]) + + if (!isOrganizer) return null + + return ( +
+ + {open && ( +
+ {queue.length === 0 ? ( +

Пока никто не поднял руку

+ ) : ( +
    + {queue.map((entry, index) => ( +
  1. + {index + 1} + + + +
  2. + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/room/HandQueuePanel.tsx b/frontend/src/components/room/HandQueuePanel.tsx deleted file mode 100644 index 1fdba37..0000000 --- a/frontend/src/components/room/HandQueuePanel.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Hand, X } from 'lucide-react' -import { useIsOrganizer } from '@/hooks/useIsOrganizer' -import type { HandQueueEntry } from '@/hooks/useChat' - -interface HandQueuePanelProps { - queue: HandQueueEntry[] - onLower: (identity: string) => void - onClose: () => void -} - -/** - * Панель очереди поднятых рук — видна только организатору (задача B1). - * Визуально — тот же боковой контейнер, что и `ChatPanel` (`.chat-panel`, - * включая мобильное поведение «во весь экран» на ≤900px), содержимое своё: - * упорядоченный список с позицией и кнопкой «Опустить» на каждой строке — - * организатору разрешено опускать чужую руку (решение оператора, задача B1). - * - * `RoomPage` гейтит рендер по `handQueueOpen` (как и `ChatPanel` по - * `chatOpen`) — свой `useIsOrganizer()` здесь ДОПОЛНИТЕЛЬНАЯ, а не - * единственная защита: `RoomToolbar` уже не показывает кнопку открытия - * не-организатору, это подстраховка на случай прямого рендера. - */ -export function HandQueuePanel({ queue, onLower, onClose }: HandQueuePanelProps) { - const isOrganizer = useIsOrganizer() - if (!isOrganizer) return null - - return ( - - ) -} diff --git a/frontend/src/components/room/RoomToolbar.tsx b/frontend/src/components/room/RoomToolbar.tsx index a6bba41..ad58bed 100644 --- a/frontend/src/components/room/RoomToolbar.tsx +++ b/frontend/src/components/room/RoomToolbar.tsx @@ -1,6 +1,5 @@ import { Hand, - ListOrdered, LogOut, Maximize, MessageSquare, @@ -18,9 +17,9 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { useToast } from '@/components/ui/ToastProvider' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' -import { useIsOrganizer } from '@/hooks/useIsOrganizer' import type { HandQueueEntry } from '@/hooks/useChat' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' +import { HandQueueMenu } from '@/components/room/HandQueueMenu' /** * Опции захвата демонстрации экрана: `audio: true` — звук @@ -67,8 +66,8 @@ interface RoomToolbarProps extends StageViewProps { handQueue: HandQueueEntry[] onRaiseHand: () => void onLowerHand: () => void - handQueueOpen: boolean - onToggleHandQueue: () => void + /** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */ + onLowerHandById: (identity: string) => void } /** @@ -104,8 +103,7 @@ export function RoomToolbar({ handQueue, onRaiseHand, onLowerHand, - handQueueOpen, - onToggleHandQueue, + onLowerHandById, layoutMode, onLayoutModeChange, hideOthers, @@ -113,7 +111,6 @@ export function RoomToolbar({ }: RoomToolbarProps) { const toast = useToast() const isCompact = useIsCompactViewport() - const isOrganizer = useIsOrganizer() const { localParticipant } = useLocalParticipant() const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) const mic = useTrackToggle({ source: Track.Source.Microphone }) @@ -197,23 +194,7 @@ export function RoomToolbar({ Рука - {isOrganizer && ( - - )} + {!isCompact && ( setChatOpen(false)} /> )} - {handQueueOpen && ( - chat.lowerHand(identity)} - onClose={() => setHandQueueOpen(false)} - /> - )} chat.lowerHand()} - handQueueOpen={handQueueOpen} - onToggleHandQueue={() => setHandQueueOpen((open) => !open)} + onLowerHandById={(identity) => chat.lowerHand(identity)} layoutMode={layoutMode} onLayoutModeChange={handleLayoutModeChange} hideOthers={hideOthers} diff --git a/frontend/src/styles/room.css b/frontend/src/styles/room.css index 9be629b..5ba642a 100644 --- a/frontend/src/styles/room.css +++ b/frontend/src/styles/room.css @@ -593,16 +593,22 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; } } } -/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ---------- - * Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список - * — свой. */ +/* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ---------- + * Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не + * `.chat-panel`: очередь — короткий список, а не история переписки, + * разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */ +.hand-queue-menu { width: 300px; padding: var(--space-3); } .hand-queue-list { list-style: none; margin: 0; - padding: var(--space-3); + padding: 0; display: flex; flex-direction: column; gap: var(--space-2); + /* Высота растёт вместе со списком (при 1–2 записях поповер компактный), но + не безгранично — после ~10 строк упирается в потолок и скроллится + дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */ + max-height: 460px; overflow-y: auto; } .hand-queue-item {