fix(room): очередь поднятых рук — компактный поповер вместо панели во весь экран
HandQueuePanel рендерился как боковая панель на весь экран на мобильном (.chat-panel, унаследовано от чата) — для короткого списка поднятых рук это избыточно, история переписки тут ни при чём. Переделано в HandQueueMenu — тот же самодостаточный поповер над кнопкой, что и «Вид» (StageViewMenu): своё состояние открытия, закрытие по клику вне/Escape, .tb-menu вместо боковой панели. Кнопка «Очередь» больше не получает open-состояние пропсами сверху — сама решает, открыта ли, и сама проверяет useIsOrganizer(). Список внутри растёт вместе с очередью (компактно при 1–2 поднятых руках), но не бесконечно — после ~10 строк упирается в max-height и скроллится дальше.
This commit is contained in:
99
frontend/src/components/room/HandQueueMenu.tsx
Normal file
99
frontend/src/components/room/HandQueueMenu.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="tb-menu-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${open ? ' is-panel-open' : ''}`}
|
||||
aria-pressed={open}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={open ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<ListOrdered className="lucide" aria-hidden="true" />
|
||||
{queue.length > 0 && (
|
||||
<span className="badge-count">{queue.length > 9 ? '9+' : queue.length}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Очередь</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tb-menu hand-queue-menu" role="dialog" aria-label="Очередь поднятых рук">
|
||||
{queue.length === 0 ? (
|
||||
<p className="chat-empty">Пока никто не поднял руку</p>
|
||||
) : (
|
||||
<ol className="hand-queue-list">
|
||||
{queue.map((entry, index) => (
|
||||
<li className="hand-queue-item" key={entry.identity}>
|
||||
<span className="hand-queue-position">{index + 1}</span>
|
||||
<span className="hand-queue-name">
|
||||
<Hand className="lucide" aria-hidden="true" />
|
||||
{entry.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="hand-queue-lower"
|
||||
onClick={() => onLower(entry.identity)}
|
||||
>
|
||||
Опустить
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<aside className="chat-panel hand-queue-panel">
|
||||
<div className="chat-head">
|
||||
<h2>Поднятые руки</h2>
|
||||
<button type="button" aria-label="Закрыть очередь" onClick={onClose}>
|
||||
<X className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<p className="chat-empty">Пока никто не поднял руку</p>
|
||||
) : (
|
||||
<ol className="hand-queue-list">
|
||||
{queue.map((entry, index) => (
|
||||
<li className="hand-queue-item" key={entry.identity}>
|
||||
<span className="hand-queue-position">{index + 1}</span>
|
||||
<span className="hand-queue-name">
|
||||
<Hand className="lucide" aria-hidden="true" />
|
||||
{entry.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="hand-queue-lower"
|
||||
onClick={() => onLower(entry.identity)}
|
||||
>
|
||||
Опустить
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
<span className="label">Рука</span>
|
||||
</button>
|
||||
|
||||
{isOrganizer && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${handQueueOpen ? ' is-panel-open' : ''}`}
|
||||
aria-pressed={handQueueOpen}
|
||||
aria-label={handQueueOpen ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
|
||||
onClick={onToggleHandQueue}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<ListOrdered className="lucide" aria-hidden="true" />
|
||||
{handQueue.length > 0 && (
|
||||
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Очередь</span>
|
||||
</button>
|
||||
)}
|
||||
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
|
||||
|
||||
{!isCompact && (
|
||||
<StageViewMenu
|
||||
|
||||
@@ -16,7 +16,6 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
|
||||
import { RoomStage } from '@/components/room/RoomStage'
|
||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
|
||||
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||
@@ -147,11 +146,6 @@ export function RoomPage() {
|
||||
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
||||
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
||||
|
||||
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
|
||||
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
|
||||
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
|
||||
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
|
||||
const [handQueueOpen, setHandQueueOpen] = useState(false)
|
||||
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
|
||||
// на каждой плитке сцены (см. `RoomParticipantTile`).
|
||||
const raisedHandIdentities = useMemo(
|
||||
@@ -304,13 +298,6 @@ export function RoomPage() {
|
||||
onClose={() => setChatOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{handQueueOpen && (
|
||||
<HandQueuePanel
|
||||
queue={chat.handQueue}
|
||||
onLower={(identity) => chat.lowerHand(identity)}
|
||||
onClose={() => setHandQueueOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<RoomToolbar
|
||||
chatVisible={chatVisible}
|
||||
@@ -327,8 +314,7 @@ export function RoomPage() {
|
||||
handQueue={chat.handQueue}
|
||||
onRaiseHand={chat.raiseHand}
|
||||
onLowerHand={() => chat.lowerHand()}
|
||||
handQueueOpen={handQueueOpen}
|
||||
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
|
||||
onLowerHandById={(identity) => chat.lowerHand(identity)}
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={handleLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user