Compare commits
3 Commits
daaa480f03
...
e8af2fce10
| Author | SHA1 | Date | |
|---|---|---|---|
| e8af2fce10 | |||
| d7ae462ed5 | |||
| 826b7639b1 |
@@ -6,13 +6,15 @@ import { useModalDismiss } from '@/hooks/useModalDismiss'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
||||||
* сотни килобайт ради десятка кнопок в поповере.
|
* сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
|
||||||
|
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
|
||||||
|
* добавляя/убирая эмодзи, держи кратность 5).
|
||||||
*/
|
*/
|
||||||
const EMOJI_OPTIONS = [
|
const EMOJI_OPTIONS = [
|
||||||
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
||||||
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
||||||
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
||||||
'🐎',
|
'🐎', '🦾', '🚀', '🦞', '💯', '🤷♂️',
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
|
|||||||
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 {
|
import {
|
||||||
Hand,
|
Hand,
|
||||||
ListOrdered,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Maximize,
|
Maximize,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -18,9 +17,9 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
|||||||
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
|
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||||
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
|
|
||||||
import type { HandQueueEntry } from '@/hooks/useChat'
|
import type { HandQueueEntry } from '@/hooks/useChat'
|
||||||
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||||
|
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Опции захвата демонстрации экрана: `audio: true` — звук
|
* Опции захвата демонстрации экрана: `audio: true` — звук
|
||||||
@@ -67,8 +66,8 @@ interface RoomToolbarProps extends StageViewProps {
|
|||||||
handQueue: HandQueueEntry[]
|
handQueue: HandQueueEntry[]
|
||||||
onRaiseHand: () => void
|
onRaiseHand: () => void
|
||||||
onLowerHand: () => void
|
onLowerHand: () => void
|
||||||
handQueueOpen: boolean
|
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
|
||||||
onToggleHandQueue: () => void
|
onLowerHandById: (identity: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,8 +103,7 @@ export function RoomToolbar({
|
|||||||
handQueue,
|
handQueue,
|
||||||
onRaiseHand,
|
onRaiseHand,
|
||||||
onLowerHand,
|
onLowerHand,
|
||||||
handQueueOpen,
|
onLowerHandById,
|
||||||
onToggleHandQueue,
|
|
||||||
layoutMode,
|
layoutMode,
|
||||||
onLayoutModeChange,
|
onLayoutModeChange,
|
||||||
hideOthers,
|
hideOthers,
|
||||||
@@ -113,7 +111,6 @@ export function RoomToolbar({
|
|||||||
}: RoomToolbarProps) {
|
}: RoomToolbarProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isCompact = useIsCompactViewport()
|
const isCompact = useIsCompactViewport()
|
||||||
const isOrganizer = useIsOrganizer()
|
|
||||||
const { localParticipant } = useLocalParticipant()
|
const { localParticipant } = useLocalParticipant()
|
||||||
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
|
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
|
||||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||||
@@ -197,23 +194,7 @@ export function RoomToolbar({
|
|||||||
<span className="label">Рука</span>
|
<span className="label">Рука</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOrganizer && (
|
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCompact && (
|
{!isCompact && (
|
||||||
<StageViewMenu
|
<StageViewMenu
|
||||||
@@ -266,7 +247,14 @@ export function RoomToolbar({
|
|||||||
<span className="icon-shell">
|
<span className="icon-shell">
|
||||||
<PictureInPicture2 className="lucide" aria-hidden="true" />
|
<PictureInPicture2 className="lucide" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span className="label">Мини-окно</span>
|
{/* На узком экране («Мини-окно» иначе переносится на 2 строки и
|
||||||
|
кнопка становится выше соседних, см. .label-full/.label-short
|
||||||
|
в room.css) — только «Мини». Текст, не структура: aria-label
|
||||||
|
выше уже несёт полный смысл независимо от видимой подписи. */}
|
||||||
|
<span className="label">
|
||||||
|
<span className="label-full">Мини-окно</span>
|
||||||
|
<span className="label-short">Мини</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
|
|||||||
import { RoomStage } from '@/components/room/RoomStage'
|
import { RoomStage } from '@/components/room/RoomStage'
|
||||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||||
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
|
|
||||||
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
||||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
@@ -147,11 +146,6 @@ export function RoomPage() {
|
|||||||
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
||||||
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
||||||
|
|
||||||
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
|
|
||||||
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
|
|
||||||
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
|
|
||||||
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
|
|
||||||
const [handQueueOpen, setHandQueueOpen] = useState(false)
|
|
||||||
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
|
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
|
||||||
// на каждой плитке сцены (см. `RoomParticipantTile`).
|
// на каждой плитке сцены (см. `RoomParticipantTile`).
|
||||||
const raisedHandIdentities = useMemo(
|
const raisedHandIdentities = useMemo(
|
||||||
@@ -304,13 +298,6 @@ export function RoomPage() {
|
|||||||
onClose={() => setChatOpen(false)}
|
onClose={() => setChatOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{handQueueOpen && (
|
|
||||||
<HandQueuePanel
|
|
||||||
queue={chat.handQueue}
|
|
||||||
onLower={(identity) => chat.lowerHand(identity)}
|
|
||||||
onClose={() => setHandQueueOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<RoomToolbar
|
<RoomToolbar
|
||||||
chatVisible={chatVisible}
|
chatVisible={chatVisible}
|
||||||
@@ -327,8 +314,7 @@ export function RoomPage() {
|
|||||||
handQueue={chat.handQueue}
|
handQueue={chat.handQueue}
|
||||||
onRaiseHand={chat.raiseHand}
|
onRaiseHand={chat.raiseHand}
|
||||||
onLowerHand={() => chat.lowerHand()}
|
onLowerHand={() => chat.lowerHand()}
|
||||||
handQueueOpen={handQueueOpen}
|
onLowerHandById={(identity) => chat.lowerHand(identity)}
|
||||||
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
|
|
||||||
layoutMode={layoutMode}
|
layoutMode={layoutMode}
|
||||||
onLayoutModeChange={handleLayoutModeChange}
|
onLayoutModeChange={handleLayoutModeChange}
|
||||||
hideOthers={hideOthers}
|
hideOthers={hideOthers}
|
||||||
|
|||||||
@@ -277,32 +277,54 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
|
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||||
|
|
||||||
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
|
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
|
||||||
|
/*
|
||||||
|
* Кнопки тулбара плавно уменьшаются (иконка/отступы/шрифт/зазор) на всём
|
||||||
|
* диапазоне 1200px → 600px — до этого тулбар стал шире, чем при исходном
|
||||||
|
* проектировании (задачи B1/B2 добавили «Рука»/«Очередь», раньше помещались
|
||||||
|
* без сжатия 8 кнопок, теперь до 11 — без этого блока получался
|
||||||
|
* горизонтальный оверфлоу вплоть до самого мобильного брейкпоинта, кнопки
|
||||||
|
* вылезали за края тулбара).
|
||||||
|
*
|
||||||
|
* Обычный `clamp(min, Nvw, max)` тут не подходит: подобранный `N`
|
||||||
|
* дотягивается до `max` уже на довольно узких экранах (например,
|
||||||
|
* `3vw` = 24px ровно на 800px viewport) и дальше просто стоит на потолке —
|
||||||
|
* получается не плавное сжатие в нужном диапазоне, а резкий скачок сильно
|
||||||
|
* раньше нужной ширины (поймали именно так на первой версии этого блока).
|
||||||
|
* Вместо этого — явная линейная интерполяция между двумя точками
|
||||||
|
* (600px→минимум, 1200px→максимум): `calc(MIN + (MAX-MIN) * (100vw - 600px)
|
||||||
|
* / 600)`, снаружи в `clamp()` только чтобы намертво остановиться на
|
||||||
|
* границах диапазона. Нижние границы — те же значения, что жёстко
|
||||||
|
* выставляет мобильный медиа-запрос ниже (`max-width: 600px`), поэтому
|
||||||
|
* переход в него на 600px визуально бесшовный.
|
||||||
|
*/
|
||||||
.room-toolbar {
|
.room-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-2);
|
gap: clamp(2px, calc(2px + (100vw - 600px) * 6 / 600), var(--space-2));
|
||||||
background: var(--color-room-surface);
|
background: var(--color-room-surface);
|
||||||
border-top: 1px solid var(--color-room-tile-border);
|
border-top: 1px solid var(--color-room-tile-border);
|
||||||
padding: var(--space-3) var(--space-6);
|
padding: var(--space-3) clamp(8px, calc(8px + (100vw - 600px) * 16 / 600), var(--space-6));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tb-btn {
|
.tb-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: clamp(2px, calc(2px + (100vw - 600px) * 2 / 600), 4px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 8px 18px;
|
padding:
|
||||||
|
clamp(6px, calc(6px + (100vw - 600px) * 2 / 600), 8px)
|
||||||
|
clamp(6px, calc(6px + (100vw - 600px) * 12 / 600), 18px);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-room-text-primary);
|
color: var(--color-room-text-primary);
|
||||||
min-width: 76px;
|
min-width: clamp(0px, calc((100vw - 600px) * 76 / 600), 76px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tb-btn .icon-shell {
|
.tb-btn .icon-shell {
|
||||||
width: 48px;
|
width: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
|
||||||
height: 48px;
|
height: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -311,9 +333,27 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
background: var(--color-room-tile);
|
background: var(--color-room-tile);
|
||||||
color: var(--color-room-text-primary);
|
color: var(--color-room-text-primary);
|
||||||
}
|
}
|
||||||
.tb-btn span.label { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); font-weight: 500; }
|
.tb-btn span.label {
|
||||||
|
font: var(--text-caption);
|
||||||
|
font-size: clamp(11px, calc(11px + (100vw - 600px) * 1 / 600), 12px);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: var(--color-room-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); }
|
.tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); }
|
||||||
|
|
||||||
|
/* Короткая подпись мини-окна (см. RoomToolbar.tsx) — «Мини-окно» на узком
|
||||||
|
экране переносится на 2 строки и делает эту кнопку выше соседних; ниже
|
||||||
|
порога, где начинается перенос, прячем длинный вариант и показываем
|
||||||
|
короткий «Мини» — кнопка остаётся однострочной и той же высоты, что и
|
||||||
|
остальные. */
|
||||||
|
.tb-btn .label-short { display: none; }
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.tb-btn .label-full { display: none; }
|
||||||
|
.tb-btn .label-short { display: inline; }
|
||||||
|
}
|
||||||
|
|
||||||
.tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; }
|
.tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; }
|
||||||
.tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; }
|
.tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; }
|
||||||
|
|
||||||
@@ -526,8 +566,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
}
|
}
|
||||||
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
/* Триггер и опции эмодзи-поповера лежат внутри `.chat-input-row` (форма
|
||||||
|
отправки) — тот же контейнер, что у круглой зелёной кнопки «Отправить»
|
||||||
|
(`.chat-input-row button`, специфичность 0,1,1). Голого класса
|
||||||
|
(`.chat-emoji-trigger`/`.chat-emoji-option`, 0,1,0) для победы над ней не
|
||||||
|
хватает — ЛЮБАЯ кнопка внутри формы (включая кнопки в самом поповере,
|
||||||
|
он тоже в этом поддереве) красилась в зелёный независимо от порядка
|
||||||
|
правил в файле. Каждый селектор ниже уточнён родительским классом ровно
|
||||||
|
затем, чтобы обойти именно эту гонку специфичности. */
|
||||||
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
|
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
|
||||||
.chat-emoji-trigger {
|
.chat-emoji-wrap .chat-emoji-trigger {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -539,35 +587,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
.chat-emoji-wrap .chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
||||||
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
.chat-emoji-wrap .chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
||||||
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
.chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
|
||||||
|
неполной последней строки. */
|
||||||
.chat-emoji-popover {
|
.chat-emoji-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + var(--space-2));
|
bottom: calc(100% + var(--space-2));
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
width: 224px;
|
width: 220px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-room-tile-border);
|
border: 1px solid var(--color-room-tile-border);
|
||||||
background: var(--color-room-surface-raised);
|
background: var(--color-room-surface-raised);
|
||||||
box-shadow: var(--shadow-room-panel);
|
box-shadow: var(--shadow-room-panel);
|
||||||
}
|
}
|
||||||
.chat-emoji-option {
|
.chat-emoji-popover .chat-emoji-option {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.chat-emoji-option:hover { background: var(--color-room-tile); }
|
.chat-emoji-popover .chat-emoji-option:hover { background: var(--color-room-tile); }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
@@ -580,16 +633,22 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ----------
|
/* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ----------
|
||||||
* Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список
|
* Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не
|
||||||
* — свой. */
|
* `.chat-panel`: очередь — короткий список, а не история переписки,
|
||||||
|
* разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */
|
||||||
|
.hand-queue-menu { width: 300px; padding: var(--space-3); }
|
||||||
.hand-queue-list {
|
.hand-queue-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-3);
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
/* Высота растёт вместе со списком (при 1–2 записях поповер компактный), но
|
||||||
|
не безгранично — после ~10 строк упирается в потолок и скроллится
|
||||||
|
дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */
|
||||||
|
max-height: 460px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.hand-queue-item {
|
.hand-queue-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user